Contract Generation Software

Template-Based PDF Document Generation in Python


Overview

Template-based PDF document creation is the process of using ready-made templates and variable data to create professional, standardized documents. This document creation method is commonly used in business environments to streamline and automate the creation of documents such as invoices, bills, reports, etc.

Python is a programming language known for its flexibility and effectiveness in automating tasks. EDocGen allows users to generate PDF documents in Python using templates. With it, businesses can quickly and easily generate thousands of documents from various data sources such as JSON, XML, Excel, databases, and business applications. Generated documents can be distributed through diverse channels such as email, e-signature, etc. to provide a personalized and end-to-end experience to customers.

This article describes the system’s possibilities for the template-based generation of PDF documents using Python 3.14.

Prerequisites

Before you start, make sure you have Python 3.14 (the current release — 3.12 or later will also work fine for everything in this article). Check your version with:

          python --version
        

Then set up a project with a virtual environment and the two dependencies this example uses — requests for HTTP and python-dotenv for loading credentials from a .env file:

          python -m venv .venv
source .venv/bin/activate  # .venv\Scripts\activate on Windows
pip install requests python-dotenv
        

pyproject.toml:

          [project]
name = "edocgen-pdf-generation-example"
version = "1.0.0"
description = "Template-based PDF document generation example using the EDocGen REST API"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.32",
    "python-dotenv>=1.0",
]

[tool.setuptools.packages.find]
where = ["src"]
        

Copy .env.example to .env and fill in your real credentials — this file is never committed to source control:

        EDOCGEN_BASE_URL=https://app.edocgen.com
EDOCGEN_USERNAME=
EDOCGEN_PASSWORD=
EDOCGEN_DEFAULT_TEMPLATE_ID=
      

Project Structure

        pyproject.toml
.env.example
src/
  edocgen/
    __init__.py
    client.py
    models.py
    exceptions.py
examples/
  _bootstrap.py
  generate_from_json.py
  generate_from_database.py
  email_output.py
      

EDocGenClient: The single class that talks to the EDocGen API — login, template lookup, document generation from JSON or a database, polling for output, downloading, and emailing.

OutputFormat: A StrEnum (PDF, DOCX, PPTX) that replaces the loose "pdf" string literals scattered through the original functions.

DatabaseSource: A frozen dataclass holding the database vendor, connection URL, query, and row limit for database-driven generation, replacing the six loose local variables in the original downloadBulkDB() method.

Exceptions: LoginError and DocumentGenerationError (both extending a base EDocGenError) so calling code can catch specific failure modes instead of letting an unrelated KeyError propagate from three calls deep.

Automated Document Generation Using Python

EDocGen’s API makes it easy for developers to automate document generation with just a few lines of code. It supports various modes such as:

  1. On-demand generation
  2. Bulk generation
  3. Using a database (SQL or NoSQL)

Its flexibility makes it suitable for use in any document generation scenario within an enterprise. We’ll go through each use case, using the same three-step flow as before — authenticate, generate, then download or email — just backed by a proper client class this time.

Authentication

The API uses a JWT-based authentication token for all API interactions. You register and obtain an access token by passing your username and password to the /login endpoint. You’ll need this token for every subsequent API call.

Here’s login() and the property that triggers it automatically:

          def login(self) -> str:
    """Authenticates against ``/login`` and caches the access token.

    Called automatically on first use — you rarely need to call this directly.
    """
    try:
        response = self._session.post(
            f"{self._base_url}/login",
            json={"username": self._username, "password": self._password},
            timeout=30,
        )
        response.raise_for_status()
    except requests.RequestException as exc:
        raise LoginError(f"Unable to reach EDocGen login endpoint: {exc}") from exc

    token = response.json().get("token")
    if not token:
        raise LoginError("Login response did not include an access token.")

    self._token = token
    log.info("Authenticated with EDocGen")
    return token

@property
def _auth_headers(self) -> dict[str, str]:
    return {"x-access-token": self._token or self.login()}
        

The token now lives on self, not on the class itself — in the original example, eDocGenapi.token = x_access_token set a class attribute, which means every instance of eDocGenapi in a process would share the same token, including the last one to log in overwriting everyone else’s. Here, _auth_headers checks self._token first and only calls login() if it’s still None, so authentication happens exactly once per client instance, lazily, on whichever call needs it first.

Prepare Template

Our system gives you the flexibility to customize and upload your own templates. If you already have a document ready, your first step is to make a few quick edits to add dynamic fields, which tell the system exactly where to inject your data.

Depending on your file type, you can easily create or edit your template using Microsoft Word or a PDF editor.

To make a field dynamic, simply enclose your variables in curly braces {} . In our system, these are called Tags.

Similarly, you can add tags for images, paragraphs, hyperlinks, graphs, etc. to the template.






This is a one-time activity; every record run against the template afterward reuses it.

Upload the Template

Once the template is ready, upload it either from the system UI (Templates → Upload) or via the API:

Method Endpoint Description
POST /api/v1/document Upload template to the system
Parameter Type Description
documentFile file (formData) file to upload

Get Template ID Using Filename

Once your template is uploaded, retrieve its ID — useful for every later API call against that template:

Method Endpoint Description
GET /api/v1/document/{filename} Returns all template details
          def get_template_ids_by_filename(self, filename: str) -> list[str]:
    """Looks up template IDs by the file name they were uploaded with."""
    response = self._request(
        "GET",
        "api/v1/document",
        params={"search_column": "filename", "search_value": filename},
    )
    documents = response.json().get("documents", [])
    return [document["_id"] for document in documents]
        

Every method above goes through one shared _request() helper that turns any requests failure into a typed exception:

          def _request(
    self,
    method: str,
    path: str,
    *,
    error_cls: type[EDocGenError] = EDocGenError,
    **kwargs,
) -> requests.Response:
    try:
        response = self._session.request(
            method,
            f"{self._base_url}/{path}",
            headers=self._auth_headers,
            timeout=kwargs.pop("timeout", 30),
            **kwargs,
        )
        response.raise_for_status()
    except requests.RequestException as exc:
        raise error_cls(f"EDocGen API request failed ({method} {path}): {exc}") from exc

    return response
        

Generate Document

To generate a document, the following endpoint is used, same as before:

Method Endpoint
POST /api/v1/document/generate/bulk
Parameter Description Type
documentId id of the template Form Data
format pdf/docx (must be supported by the template) Form Data
outputFileName the file name for the output file Form Data
inputFile file containing marker values — JSON, XLSX, or XML Form Data

This endpoint supports two modes:

Synchronous generation — for a single record, generated immediately.

Asynchronous generation — for multiple records; the API automatically compresses the output into a .zip file.

Here’s the JSON-driven generation method:

          def generate_from_json(
    self,
    json_file_path: Path | str,
    document_id: str,
    output_format: OutputFormat = OutputFormat.PDF,
) -> str:
    """Submits a JSON data file for bulk document generation.

    Returns the generated output file name, used to poll for completion.
    """
    json_file_path = Path(json_file_path)
    if not json_file_path.is_file():
        raise DocumentGenerationError(f"Cannot read input file: {json_file_path}")

    output_file_name = str(uuid.uuid4())

    with json_file_path.open("rb") as input_file:
        files = {"inputFile": (json_file_path.name, input_file, "application/octet-stream")}
        data = {
            "documentId": document_id,
            "format": output_format.value,
            "outputFileName": output_file_name,
        }
        self._request(
            "POST",
            "api/v1/document/generate/bulk",
            data=data,
            files=files,
            error_cls=DocumentGenerationError,
        )

    return output_file_name
        

Two things worth calling out. First, json_file_path is opened with a with block, so the file handle is guaranteed to close — the original example passed open(inputFile, 'rb') straight into the files dict with nothing ever closing it. Second, this method only submits the generation request and returns the output file name; polling and downloading are separate methods (below) rather than being folded into the same function, so a caller that just wants to kick off generation and check back later isn’t forced to also sit in a retry loop.

Generate Bulk Document From Database

The system also supports generating documents straight from a database — SQL tables, MongoDB collections, and so on — using the same endpoint, with extra metadata: connection details, row limit, and the query itself.

Parameter Type Description
documentId string (formData) id of the template
dbVendor string (formData) database vendor
dbUrl string (formData) database connection URL
dbPassword string (formData) database password
dbQuery string (formData) database select query
dbLimit string (formData) limit on rows fetched
format string (formData) output format, docx or pdf — default docx
outputFileName string (formData) file name for the output file
          @dataclass(frozen=True, slots=True)
class DatabaseSource:
    """Connection and query details for generating documents directly from a database."""

    vendor: str
    url: str
    password: str
    query: str
    limit: int
    key_to_file_name: str = "prefix"
        
          def generate_from_database(
    self,
    source: DatabaseSource,
    document_id: str,
    output_format: OutputFormat = OutputFormat.PDF,
) -> str:
    """Submits a database query as the data source for bulk document generation.

    Returns the generated output file name, used to poll for completion.
    """
    output_file_name = str(uuid.uuid4())

    data = {
        "documentId": document_id,
        "format": output_format.value,
        "outputFileName": output_file_name,
        "dbVendor": source.vendor,
        "dbUrl": source.url,
        "dbPassword": source.password,
        "dbQuery": source.query,
        "dbLimit": str(source.limit),
        "keyToFileName": source.key_to_file_name,
    }
    self._request(
        "POST",
        "api/v1/document/generate/bulk",
        data=data,
        error_cls=DocumentGenerationError,
    )

    return output_file_name
        

frozen=True, slots=True on the dataclass means a DatabaseSource can’t be mutated after it’s built and doesn’t carry the overhead of a per-instance __dict__ — a small but free win for a value object that’s only ever passed around and read.

Wait For and Download the Output

Both generation paths return an output file name that needs to be polled until EDocGen finishes processing it. The original example did this with while(not output): and no delay — a tight loop hammering the API as fast as Python could issue requests. This version polls on a fixed interval and gives up with a clear exception if the output never appears:

          def wait_for_output(
    self,
    output_file_name: str,
    output_format: OutputFormat = OutputFormat.PDF,
    max_retries: int = 50,
    delay_seconds: float = 2.0,
) -> str:
    """Polls the output endpoint until the generated file is ready (or retries run out)."""
    search_name = f"{output_file_name}.{output_format.value}{output_format.archive_extension}"

    for attempt in range(1, max_retries + 1):
        response = self._request("GET", f"api/v1/output/name/{search_name}")
        output = response.json().get("output") or []

        if output:
            return output[0]["_id"]

        log.info("Waiting for %s to finish generating (attempt %d/%d)", search_name, attempt, max_retries)
        time.sleep(delay_seconds)

    raise DocumentGenerationError(
        f"Output '{output_file_name}' was not ready after {max_retries} attempts."
    )

def download_output(self, output_id: str, destination: Path | str) -> Path:
    """Downloads a generated output to a local file path."""
    destination = Path(destination)
    response = self._request(
        "GET",
        f"api/v1/output/download/{output_id}",
        error_cls=DocumentGenerationError,
        stream=True,
    )

    destination.parent.mkdir(parents=True, exist_ok=True)
    with destination.open("wb") as out_file:
        for chunk in response.iter_content(chunk_size=8192):
            out_file.write(chunk)

    return destination
        

stream=True plus iter_content() downloads the file in chunks instead of loading the whole response into memory at once — worth knowing about for large .zip archives of bulk-generated documents.

Email the Generated Document

After generating a document, you can email it straight to a recipient instead of downloading it — useful for invoices, reports, and contracts delivered directly to clients and stakeholders:

          def email_output(self, output_id: str, email_address: str) -> None:
    """Emails a generated output to the given address."""
    self._request(
        "POST",
        "api/v1/output/email",
        data={"outId": output_id, "emailId": email_address},
    )
        

Putting It Together

With the client written, a full generate-and-download run looks like this:

          """Generate a PDF from JSON_Data.json and download it."""

from __future__ import annotations

import logging
from pathlib import Path

from _bootstrap import default_template_id, edocgen_client

from edocgen import DocumentGenerationError, OutputFormat

logging.basicConfig(level=logging.INFO)

JSON_FILE_PATH = Path(__file__).parent / "JSON_Data.json"


def main() -> None:
    document_id = default_template_id()

    with edocgen_client() as client:
        try:
            output_file_name = client.generate_from_json(JSON_FILE_PATH, document_id, OutputFormat.PDF)
            print(f"Submitted for generation as '{output_file_name}', waiting for output...")

            output_id = client.wait_for_output(output_file_name, OutputFormat.PDF)

            destination = Path(__file__).parent / "output" / f"{output_file_name}.zip"
            client.download_output(output_id, destination)

            print(f"Downloaded to {destination}")
        except DocumentGenerationError as exc:
            raise SystemExit(f"Document generation failed: {exc}") from exc


if __name__ == "__main__":
    main()
        

with edocgen_client() as client: uses the context manager on EDocGenClient to guarantee the underlying requests.Session gets closed even if generation fails partway through — the original example never closed anything, since the plain requests module doesn’t hold a persistent connection open the way a Session does.

Run it with:

          python examples/generate_from_json.py
        

Conclusion

In summary, EDocGen remains a powerful tool for generating automated documents from templates, with support across languages including Python. It simplifies the process of creating complex documents using placeholders and data, and offers a range of formatting and layout options.