diff --git a/docs/index.rst b/docs/index.rst index 46cf69f..95fc14e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -471,21 +471,29 @@ Command-line execution One can use `docxtpl` module directly on command line to generate a docx from a template and a json file as a context:: - usage: python -m docxtpl [-h] [-o] [-q] template_path json_path output_filename + usage: python -m docxtpl [-h] [-o] [-q] [--validate] [--report FILE] [--json-error] template_path [json_path] [output_filename] Make docx file from existing template docx and json data. positional arguments: template_path The path to the template docx file. - json_path The path to the json file with the data. - output_filename The filename to save the generated docx. + json_path The path to the json file with the data (not required with --validate). + output_filename The filename to save the generated docx (not required with --validate). optional arguments: -h, --help show this help message and exit -o, --overwrite If output file already exists, overwrites without asking for confirmation -q, --quiet Do not display unnecessary messages + --validate Check if the template Jinja2 syntax is valid + --report FILE Write validation result as JSON to FILE (requires --validate) + --json-error Emit errors as JSON on stderr +To validate a template without generating a document:: + + python -m docxtpl template.docx --validate + +If the syntax is invalid, the error is printed to the console. With ``--report FILE``, a JSON report is written, for example ``{"valid": false, "error": "..."}`` or ``{"valid": true}``. See tests/module_execute.py for an example. diff --git a/docxtpl/__main__.py b/docxtpl/__main__.py index 59cf049..8028611 100644 --- a/docxtpl/__main__.py +++ b/docxtpl/__main__.py @@ -1,6 +1,7 @@ import argparse import json import os +import sys from .template import DocxTemplate, TemplateError @@ -9,11 +10,30 @@ OUTPUT_ARG = "output_filename" OVERWRITE_ARG = "overwrite" QUIET_ARG = "quiet" +VALIDATE_ARG = "validate" +REPORT_ARG = "report" +JSON_ERROR_ARG = "json_error" + + +def template_error_payload(exc: TemplateError) -> dict: + payload = {"error": "template_error", "message": str(exc)} + if hasattr(exc, "docx_context"): + payload["docx_context"] = list(exc.docx_context) + return payload + + +def emit_cli_error(payload: dict, json_error: bool = False) -> None: + if json_error: + json.dump(payload, sys.stderr) + sys.stderr.write("\n") + else: + print("Error: " + payload["message"], file=sys.stderr) + sys.exit(1) def make_arg_parser(): parser = argparse.ArgumentParser( - usage="python -m docxtpl [-h] [-o] [-q] {} {} {}".format( + usage="python -m docxtpl [-h] [-o] [-q] [--validate] [--report FILE] [--json-error] {} [{}] [{}]".format( TEMPLATE_ARG, JSON_ARG, OUTPUT_ARG ), description="Make docx file from existing template docx and json data.", @@ -22,10 +42,30 @@ def make_arg_parser(): TEMPLATE_ARG, type=str, help="The path to the template docx file." ) parser.add_argument( - JSON_ARG, type=str, help="The path to the json file with the data." + JSON_ARG, + type=str, + nargs="?", + default=None, + help="The path to the json file with the data.", + ) + parser.add_argument( + OUTPUT_ARG, + type=str, + nargs="?", + default=None, + help="The filename to save the generated docx.", + ) + parser.add_argument( + "--" + VALIDATE_ARG, + action="store_true", + help="Check if the template Jinja2 syntax is valid.", ) parser.add_argument( - OUTPUT_ARG, type=str, help="The filename to save the generated docx." + "--" + REPORT_ARG, + type=str, + metavar="FILE", + default=None, + help="Write validation result as JSON to FILE (requires --validate).", ) parser.add_argument( "-" + OVERWRITE_ARG[0], @@ -39,6 +79,12 @@ def make_arg_parser(): action="store_true", help="Do not display unnecessary messages", ) + parser.add_argument( + "--json-error", + dest=JSON_ERROR_ARG, + action="store_true", + help="Emit errors as JSON on stderr", + ) return parser @@ -67,8 +113,10 @@ def is_argument_valid(arg_name, arg_value, overwrite): return arg_value.endswith(".docx") and check_exists_ask_overwrite( arg_value, overwrite ) - elif arg_name in [OVERWRITE_ARG, QUIET_ARG]: + elif arg_name in [OVERWRITE_ARG, QUIET_ARG, VALIDATE_ARG, JSON_ERROR_ARG]: return arg_value in [True, False] + elif arg_name == REPORT_ARG: + return arg_value is None or isinstance(arg_value, str) def check_exists_ask_overwrite(arg_value, overwrite): @@ -94,10 +142,30 @@ def check_exists_ask_overwrite(arg_value, overwrite): def validate_all_args(parsed_args): + if parsed_args.get(REPORT_ARG) and not parsed_args.get(VALIDATE_ARG): + raise RuntimeError("--report requires --validate.") + + if parsed_args[VALIDATE_ARG]: + template_path = parsed_args[TEMPLATE_ARG] + if not is_argument_valid(TEMPLATE_ARG, template_path, False): + raise RuntimeError( + 'The specified {arg_name} "{arg_value}" is not valid.'.format( + arg_name=TEMPLATE_ARG, arg_value=template_path + ) + ) + return + + if not parsed_args[JSON_ARG] or not parsed_args[OUTPUT_ARG]: + raise RuntimeError( + "json_path and output_filename are required in render mode." + ) + overwrite = parsed_args[OVERWRITE_ARG] # Raises AssertionError if any of the arguments is not validated try: for arg_name, arg_value in parsed_args.items(): + if arg_name in (VALIDATE_ARG, REPORT_ARG): + continue if not is_argument_valid(arg_name, arg_value, overwrite): raise AssertionError except AssertionError: @@ -108,6 +176,41 @@ def validate_all_args(parsed_args): ) +def validate_template_syntax(template_path): + try: + doc = DocxTemplate(template_path) + doc.get_undeclared_template_variables() + return None + except TemplateError as e: + return str(e) + except Exception as e: + return str(e) + + +def write_validation_report(report_path, valid, error=None): + payload = {"valid": valid} + if error is not None: + payload["error"] = error + with open(report_path, "w") as f: + json.dump(payload, f) + + +def run_validation(parsed_args): + template_path = os.path.abspath(parsed_args[TEMPLATE_ARG]) + error = validate_template_syntax(template_path) + valid = error is None + + if not valid: + print(error) + elif not parsed_args[QUIET_ARG]: + print("Template syntax is valid.") + + if parsed_args.get(REPORT_ARG): + write_validation_report(parsed_args[REPORT_ARG], valid, error) + + return 0 if valid else 1 + + def get_json_data(json_path): with open(json_path) as file: try: @@ -122,18 +225,12 @@ def get_json_data(json_path): def make_docxtemplate(template_path): - try: - return DocxTemplate(template_path) - except TemplateError: - raise RuntimeError("Could not create docx template.") + return DocxTemplate(template_path) def render_docx(doc, json_data): - try: - doc.render(json_data) - return doc - except TemplateError: - raise RuntimeError("An error ocurred while trying to render the docx") + doc.render(json_data) + return doc def save_file(doc, parsed_args): @@ -159,15 +256,23 @@ def main(): parsed_args = get_args(parser) try: validate_all_args(parsed_args) + if parsed_args[VALIDATE_ARG]: + sys.exit(run_validation(parsed_args)) json_data = get_json_data(os.path.abspath(parsed_args[JSON_ARG])) doc = make_docxtemplate(os.path.abspath(parsed_args[TEMPLATE_ARG])) doc = render_docx(doc, json_data) save_file(doc, parsed_args) + except TemplateError as e: + emit_cli_error( + template_error_payload(e), json_error=parsed_args[JSON_ERROR_ARG] + ) except RuntimeError as e: - print("Error: " + e.__str__()) - return + emit_cli_error( + {"error": "cli_error", "message": str(e)}, + json_error=parsed_args[JSON_ERROR_ARG], + ) finally: - if not parsed_args[QUIET_ARG]: + if not parsed_args[QUIET_ARG] and not parsed_args[JSON_ERROR_ARG]: print("Exiting program!") diff --git a/tests/cli_validate.py b/tests/cli_validate.py new file mode 100644 index 0000000..8ed7571 --- /dev/null +++ b/tests/cli_validate.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""CLI coverage for --validate / --report exit codes.""" + +import json +import os +import subprocess +import sys +import tempfile + + +def run_cli(*args): + return subprocess.run( + [sys.executable, "-m", "docxtpl"] + list(args), + capture_output=True, + text=True, + ) + + +def check(name, result, expected_code): + ok = result.returncode == expected_code + status = "PASS" if ok else "FAIL" + print( + "%s: %s (exit %s, expected %s)" + % (status, name, result.returncode, expected_code) + ) + if not ok and result.stdout: + print(" stdout: %s" % result.stdout.strip()) + if not ok and result.stderr: + print(" stderr: %s" % result.stderr.strip()) + return ok + + +def main(): + os.chdir(os.path.dirname(__file__)) + print("Testing CLI --validate / --report ...") + print("=" * 50) + + results = [] + + results.append( + check( + "valid template + --validate", + run_cli("templates/module_execute_tpl.docx", "--validate", "-q"), + 0, + ) + ) + + results.append( + check( + "invalid Jinja template + --validate", + run_cli("templates/template_error_tpl.docx", "--validate", "-q"), + 1, + ) + ) + + results.append( + check( + "missing template + --validate", + run_cli("missing.docx", "--validate", "-q"), + 1, + ) + ) + + report_path = os.path.join(tempfile.gettempdir(), "docxtpl_cli_validate_report.json") + results.append( + check( + "--report without --validate", + run_cli("templates/module_execute_tpl.docx", "--report", report_path), + 1, + ) + ) + + # Light sanity check: valid template writes {"valid": true} + report_path = os.path.join( + tempfile.gettempdir(), "docxtpl_cli_validate_ok_report.json" + ) + if os.path.exists(report_path): + os.unlink(report_path) + result = run_cli( + "templates/module_execute_tpl.docx", + "--validate", + "--report", + report_path, + "-q", + ) + report_ok = False + if result.returncode == 0 and os.path.isfile(report_path): + with open(report_path) as f: + payload = json.load(f) + report_ok = payload == {"valid": True} + status = "PASS" if report_ok else "FAIL" + print("%s: valid template --report JSON" % status) + results.append(report_ok) + + print("=" * 50) + if all(results): + print("ALL TESTS PASSED!") + return 0 + print("SOME TESTS FAILED!") + return 1 + + +if __name__ == "__main__": + sys.exit(main())