Template

The scrippy-template module generates documents (reports, emails, configuration files...) from Jinja2 template files.

Table of content

Extending disk_watch.py

In the tutorial, disk_watch.py writes its report line by line, with the format hard coded in write_report(). Changing the layout of the report requires to change the code. With scrippy-template, the layout moves to template files that can be changed without touching the script, and a new html format comes almost for free.

The template files

The template files are stored in the disk_watch subdirectory of the templatedir directory, one file per report format: report.txt.j2, report.csv.j2 and report.html.j2.

/home/luiggi/.local/share/scrippy/templates/disk_watch/report.txt.j2:

disk_watch report - {{ params.hostname }} - {{ params.date }}
Thresholds: warning={{ params.warning }}% critical={{ params.critical }}%

{% for result in params.results -%}
{{ "%-20s" | format(result.mount_point) }} {{ "%6s" | format(result.usage ~ "%" if result.usage is not none else "-") }}  {{ result.status }}
{% endfor %}
Status: {{ params.status }}

/home/luiggi/.local/share/scrippy/templates/disk_watch/report.csv.j2:

mount_point;usage;status
{% for result in params.results -%}
{{ result.mount_point }};{{ result.usage if result.usage is not none else "" }};{{ result.status }}
{% endfor %}

/home/luiggi/.local/share/scrippy/templates/disk_watch/report.html.j2:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>disk_watch report - {{ params.hostname }}</title>
</head>
<body>
  <h1>disk_watch report - {{ params.hostname }}</h1>
  <p>{{ params.date }} - Thresholds: warning={{ params.warning }}% critical={{ params.critical }}%</p>
  <table>
    <tr><th>Mount point</th><th>Usage</th><th>Status</th></tr>
    {%- for result in params.results %}
    <tr class="{{ result.status | lower }}">
      <td>{{ result.mount_point }}</td>
      <td>{% if result.usage is not none %}{{ result.usage }}%{% else %}-{% endif %}</td>
      <td>{{ result.status }}</td>
    </tr>
    {%- endfor %}
  </table>
  <p>Status: <strong>{{ params.status }}</strong></p>
</body>
</html>

The code

Starting from the complete tutorial script, version 1.1.0 of disk_watch.py brings the following changes.

The html format is added to the --format option, and the templates are documented in the Functioning section:

    @args:format|choice|Report format (default: text)|1|text,csv,html||report|false|false
    Templates:
      - <templatedir>/disk_watch/report.<format>.j2

The module is imported, after scrippy_core:

import socket
import scrippy_core
from scrippy_core import logger, ScrippyCoreError
from scrippy_template import template

The new render_report() function gathers the data of the report into the params dictionary and renders the template matching the report format:

def render_report(results, report_format, warning, critical, templatedir):
  params = {"hostname": socket.gethostname(),
            "date": f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
            "warning": warning,
            "critical": critical,
            "status": STATUS[max(status for _, _, status in results)],
            "results": [{"mount_point": mount_point, "usage": usage, "status": STATUS[status]}
                        for mount_point, usage, status in results]}
  renderer = template.Renderer(os.path.join(templatedir, "disk_watch"),
                               f"report.{EXTENSIONS[report_format]}.j2")
  return renderer.render(params)

write_report() now writes the rendered content:

def write_report(content, report_format, workspace_path, reportdir):
  name = f"disk_watch_{datetime.datetime.now():%Y%m%d_%H%M%S}.{EXTENSIONS[report_format]}"
  tmp_report = os.path.join(workspace_path, name)
  logger.info(f"[+] Writing report: {tmp_report}")
  with open(tmp_report, mode="w", encoding="utf-8") as report:
    report.write(content)
  report_dir = os.path.join(reportdir, "disk_watch")
  os.makedirs(report_dir, exist_ok=True)
  logger.info(f"[+] Publishing report into {report_dir}")
  shutil.move(tmp_report, os.path.join(report_dir, name))

And main() renders the report before writing it:

    if args.report:
      report_format = args.format or "text"
      content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
      write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
Complete script
#!/usr/bin/env python3
"""
--------------------------------------------------------------------------------
  @author         : Luiggi Vercotti
  @date           : 2026-10-08
  @version        : 1.1.0
  @description    : Monitor filesystem usage

--------------------------------------------------------------------------------
  Update:
  0.1.0  2026-10-08 - Luiggi Vercotti - cre: Script creation
  0.2.0  2026-10-08 - Luiggi Vercotti - evo: Several mount points, log levels
  0.3.0  2026-10-08 - Luiggi Vercotti - evo: Monitoring exit status
  0.4.0  2026-10-08 - Luiggi Vercotti - evo: Configuration file
  0.5.0  2026-10-08 - Luiggi Vercotti - evo: Command line options
  0.6.0  2026-10-08 - Luiggi Vercotti - evo: Alerting API token
  0.7.0  2026-10-08 - Luiggi Vercotti - evo: Report file
  0.8.0  2026-10-08 - Luiggi Vercotti - evo: Error handling
  0.9.0  2026-10-08 - Luiggi Vercotti - evo: Access control
  1.0.0  2026-10-08 - Luiggi Vercotti - evo: Concurrent executions
  1.1.0  2026-10-08 - Luiggi Vercotti - evo: Report templates

--------------------------------------------------------------------------------
  List of authorized users or group:
    @group:ops

--------------------------------------------------------------------------------
  Concurrent executions:
    @max_instance: 1
    @exit_on_wait: true

--------------------------------------------------------------------------------
  List of mandatory configuration parameters:
    @conf:filesystems|mount_points|str|false
    @conf:thresholds|warning|int|false
    @conf:thresholds|critical|int|false
    @conf:alerting|token|str|true

--------------------------------------------------------------------------------
  List of execution options and argument parameters:
    @args:mount-point|str|Mount points to check (default: from configuration)|+||exclude||false|false
    @args:exclude|str|Mount points to skip|+||||false|false
    @args:warning|int|Warning threshold in percent (default: from configuration)|1||||false|false
    @args:critical|int|Critical threshold in percent (default: from configuration)|1||||false|false
    @args:token|str|Alerting API token (default: from configuration)|1||||false|true
    @args:report|bool|Write a report into the report directory||false|||false|false
    @args:format|choice|Report format (default: text)|1|text,csv,html||report|false|false

--------------------------------------------------------------------------------
  Functioning:
  ---------------
    Checks the usage of each mount point against a warning and a critical
    threshold and exits with a monitoring compatible exit status:
      0: OK, 1: WARNING, 2: CRITICAL, 3: UNKNOWN (a mount point can not be checked)

    Configuration:
      - filesystems::mount_points: Comma separated list of mount points
      - thresholds::warning: Warning threshold in percent
      - thresholds::critical: Critical threshold in percent
      - alerting::token: Alerting API token (secret)

    Templates:
      - <templatedir>/disk_watch/report.<format>.j2
"""
# ------------------------------------------------------------------------------
#  Initialization of the environment
# ------------------------------------------------------------------------------
import os
import sys
import shutil
import datetime
import socket
import scrippy_core
from scrippy_core import logger, ScrippyCoreError
from scrippy_template import template

OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3
STATUS = {OK: "OK", WARNING: "WARNING", CRITICAL: "CRITICAL", UNKNOWN: "UNKNOWN"}
EXTENSIONS = {"text": "txt", "csv": "csv", "html": "html"}

# ------------------------------------------------------------------------------
#  Definition of functions and classes
# ------------------------------------------------------------------------------
def get_mount_points(config, args):
  if args.mount_point:
    return args.mount_point
  mount_points = [mp.strip() for mp in config.get("filesystems", "mount_points").split(",")]
  return [mp for mp in mount_points if mp not in (args.exclude or [])]

def get_thresholds(config, args):
  warning = args.warning if args.warning is not None else config.get("thresholds", "warning", "int")
  critical = args.critical if args.critical is not None else config.get("thresholds", "critical", "int")
  if not 0 &lt; warning &lt; critical &lt;= 100:
    raise ScrippyCoreError(f"Invalid thresholds: warning={warning}, critical={critical}")
  return warning, critical

def get_usage(mount_point):
  logger.debug(f"[+] Checking {mount_point}")
  usage = shutil.disk_usage(mount_point)
  return round(usage.used * 100 / usage.total, 1)

def check(mount_points, warning, critical):
  results = []
  for mount_point in mount_points:
    try:
      usage = get_usage(mount_point)
    except OSError as err:
      logger.error(f" '-&gt; {mount_point}: {err.strerror}")
      results.append((mount_point, None, UNKNOWN))
      continue
    if usage &gt;= critical:
      logger.error(f" '-&gt; {mount_point}: {usage}% [CRITICAL]")
      status = CRITICAL
    elif usage &gt;= warning:
      logger.warning(f" '-&gt; {mount_point}: {usage}% [WARNING]")
      status = WARNING
    else:
      logger.info(f" '-&gt; {mount_point}: {usage}% [OK]")
      status = OK
    results.append((mount_point, usage, status))
  return results

def render_report(results, report_format, warning, critical, templatedir):
  params = {"hostname": socket.gethostname(),
            "date": f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
            "warning": warning,
            "critical": critical,
            "status": STATUS[max(status for _, _, status in results)],
            "results": [{"mount_point": mount_point, "usage": usage, "status": STATUS[status]}
                        for mount_point, usage, status in results]}
  renderer = template.Renderer(os.path.join(templatedir, "disk_watch"),
                               f"report.{EXTENSIONS[report_format]}.j2")
  return renderer.render(params)

def write_report(content, report_format, workspace_path, reportdir):
  name = f"disk_watch_{datetime.datetime.now():%Y%m%d_%H%M%S}.{EXTENSIONS[report_format]}"
  tmp_report = os.path.join(workspace_path, name)
  logger.info(f"[+] Writing report: {tmp_report}")
  with open(tmp_report, mode="w", encoding="utf-8") as report:
    report.write(content)
  report_dir = os.path.join(reportdir, "disk_watch")
  os.makedirs(report_dir, exist_ok=True)
  logger.info(f"[+] Publishing report into {report_dir}")
  shutil.move(tmp_report, os.path.join(report_dir, name))

# ------------------------------------------------------------------------------
#  Main processing
# ------------------------------------------------------------------------------
def main():
  with scrippy_core.ScriptContext(retention=100) as _context:
    config = _context.config
    args = _context.args
    token = args.token or config.get("alerting", "token")
    logger.debug(f"[+] Alerting API token: {token}")
    warning, critical = get_thresholds(config, args)
    logger.log_section("disk_watch", f"Thresholds: warning={warning}% critical={critical}%")
    results = check(get_mount_points(config, args), warning, critical)
    if args.report:
      report_format = args.format or "text"
      content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
      write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
    status = max(status for _, _, status in results)
    logger.info(f"[+] Status: {STATUS[status]}")
    if status != OK:
      sys.exit(status)

# ------------------------------------------------------------------------------
#  Entry point
# ------------------------------------------------------------------------------
if __name__ == '__main__':
  main()

The result

./disk_watch.py --report --format text --mount-point / /home /nope
disk_watch report - srv.flying.circus - 2026-10-08 19:21:40
Thresholds: warning=80% critical=90%

/                     34.9%  OK
/home                 61.2%  OK
/nope                     -  UNKNOWN

Status: UNKNOWN

With --format html, the mount point names are automatically escaped, since the template file name ends with .html.j2 (see HTML and XML escaping).

Installation

pip install scrippy-template

scrippy-template requires Python 3.11 or later and Jinja2, installed automatically.

Rendering a template

from scrippy_template import template

renderer = template.Renderer(base_path, template_filename)
content = renderer.render(params)

template.Renderer(base_path, template_filename)

Loads a template file.

Parameter Description
base_path The directory where the template files are searched for
template_filename The template file name, relative to base_path (it may contain subdirectories)

The template file is checked as soon as it is loaded: A missing file or a syntax error raises a ScrippyTemplateError. Unknown filters and tests are only detected at rendering, since they can be added to the Jinja2 environment after loading.

By convention, Scrippy scripts store their template files in the templatedir directory, available as _context.env["templatedir"], preferably in a subdirectory named after the script.

Renderer.render(params=None)

Returns the rendering of the template file, as a string. The template file is compiled at each rendering: A change made to the template file after it has been loaded is taken into account.

Renderer.load(base_path, template_filename)

Loads another template file, possibly from another directory, in place of the previous one. The Jinja2 environment is kept.

renderer = template.Renderer("/home/luiggi/.local/share/scrippy/templates/disk_watch", "report.txt.j2")
text = renderer.render(params)
renderer.load("/home/luiggi/.local/share/scrippy/templates/disk_watch", "report.html.j2")
html = renderer.render(params)

Template variables

The variables are passed to render() as a dictionary, which the template file accesses as params:

renderer.render({"user": "Harry Fink", "errors": [{"code": 2, "msg": "Dead parrot"}]})
Hello {{ params.user }}
{% for error in params.errors -%}
- {{ error.code }}: {{ error.msg }}
{% endfor %}

All the Jinja2 features are available: Conditions, loops, filters, macros, inheritance... See the Jinja2 template designer documentation.

Undefined variables

Any variable used by the template must be present in params: An undefined variable raises a ScrippyTemplateError at rendering, instead of being silently rendered as an empty string.

Optional variables must be explicitly handled in the template, with the is defined test or the default filter:

Hello {{ params.user }}{% if params.title is defined %} ({{ params.title }}){% endif %}
Sent by {{ params.sender | default("Scrippy") }}

Note: A variable whose value is None is defined: Test it with is none, as report.txt.j2 does for the usage of an unavailable mount point.

HTML and XML escaping

Variables are automatically escaped only in the template files whose name ends with .html, .htm, .xml, .html.j2, .htm.j2 or .xml.j2. All other template files (.j2, .txt, .txt.j2...) are rendered without escaping.

With the report.html.j2 template file:

<td>{{ params.msg }}</td>

and params = {"msg": "<b>Spam & eggs</b>"}, the rendering is:

<td>&lt;b&gt;Spam &amp; eggs&lt;/b&gt;</td>

To insert trusted HTML in an escaped template, use the safe filter: {{ params.html_snippet | safe }}.

The Jinja2 environment

The Jinja2 environment is available through the env attribute of the renderer. It is created with the renderer and kept by load(). Changes made to it, such as custom filters or global variables, are taken into account at the next rendering:

renderer = template.Renderer(templatedir, "shout.j2")    # {{ params.msg | shout }}
renderer.env.filters["shout"] = str.upper
renderer.render({"msg": "Spam, spam, spam"})              # SPAM, SPAM, SPAM

Errors

All errors raise a scrippy_template.ScrippyTemplateError:

Error Raised by Message
Missing template file Renderer(), load(), render() Template not found: <base_path>/<template_filename>
Syntax error Renderer(), load(), render() Template syntax error: <base_path>/<template_filename> (line <n>): <message>
Unknown filter render() Template syntax error: <base_path>/<template_filename> (line <n>): No filter named '<filter>'.
Unknown test render() Unexpected error: [TemplateRuntimeError] No test named '<test>' found.
Undefined variable render() Undefined variable: <message>
Any other error Renderer(), load(), render() Unexpected error: [<exception class>] <message>
[CRITICAL] [ScrippyTemplateError]: Undefined variable: 'dict object' has no attribute 'hostname'
from scrippy_template import template, ScrippyTemplateError

try:
  content = template.Renderer(templatedir, "report.txt.j2").render(params)
except ScrippyTemplateError as err:
  logger.warning(f"[+] Report not generated: {err}")