The scrippy-template module generates documents (reports, emails, configuration files...) from Jinja2 template files.
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 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>
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"])
#!/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 < warning < critical <= 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" '-> {mount_point}: {err.strerror}")
results.append((mount_point, None, UNKNOWN))
continue
if usage >= critical:
logger.error(f" '-> {mount_point}: {usage}% [CRITICAL]")
status = CRITICAL
elif usage >= warning:
logger.warning(f" '-> {mount_point}: {usage}% [WARNING]")
status = WARNING
else:
logger.info(f" '-> {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()
./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).
pip install scrippy-template
scrippy-template requires Python 3.11 or later and Jinja2, installed automatically.
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)
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.
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.
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><b>Spam & eggs</b></td>
To insert trusted HTML in an escaped template, use the safe filter: {{ params.html_snippet | safe }}.
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
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}")