Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ crash.log
.vscode/
__pycache__/
.coverage

# Python packaging artifacts
*.egg
*.egg-info/
.eggs/
2 changes: 2 additions & 0 deletions src/lambdacron/notifications/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from lambdacron.notifications.base import (
EnvVarTemplateProvider,
FileTemplateProvider,
RenderedTemplateNotificationHandler,
TemplateProvider,
)
Expand All @@ -9,6 +10,7 @@
__all__ = [
"EmailNotificationHandler",
"EnvVarTemplateProvider",
"FileTemplateProvider",
"PrintNotificationHandler",
"RenderedTemplateNotificationHandler",
"TemplateProvider",
Expand Down
26 changes: 26 additions & 0 deletions src/lambdacron/notifications/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import os
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Mapping, Optional

from jinja2 import Environment, StrictUndefined
Expand Down Expand Up @@ -58,6 +59,31 @@ def get_template(self) -> str:
return template


class FileTemplateProvider(TemplateProvider):
"""
Load a notification template from a file on disk.

Parameters
----------
path : pathlib.Path
Path to a file containing the template string.
"""

def __init__(self, path: Path) -> None:
self.path = path

def get_template(self) -> str:
"""
Return a Jinja2 template string from the configured file path.

Returns
-------
str
Template contents as a string.
"""
return self.path.read_text(encoding="utf-8")


class RenderedTemplateNotificationHandler(ABC):
"""
Base class for SQS-driven notifications using Jinja2 templates.
Expand Down
122 changes: 122 additions & 0 deletions src/lambdacron/render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Mapping, TextIO

from jinja2 import TemplateError

from lambdacron.notifications.base import (
FileTemplateProvider,
RenderedTemplateNotificationHandler,
)
Comment on lines +1 to +12

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module is intended to be run as python -m lambdacron.render, but src/lambdacron/ is an implicit namespace package (no __init__.py). With the current pyproject.toml using tool.setuptools.packages.find, top-level modules under lambdacron/ (like render.py) may not be included in built distributions unless namespace packages are explicitly enabled or a package lambdacron is created. Please ensure the packaging config includes this module in wheels/sdists (e.g., enable namespace package discovery or add src/lambdacron/__init__.py).

Copilot uses AI. Check for mistakes.


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Render a notification template using LambdaCron task output data."
)
parser.add_argument(
"output_json",
metavar="output.json",
nargs="?",
default="-",
help=(
"JSON file containing the direct output from `_perform_task` "
"(a result-type-to-payload object). Use '-' or omit this argument "
"to read from stdin."
),
)
parser.add_argument(
"-t",
"--template",
required=True,
type=Path,
help="Path to the Jinja2 template file.",
)
parser.add_argument(
"-r",
"--result-type",
required=True,
help="Result type key to emulate from the LambdaCron task output.",
)
return parser


class RenderNotificationHandler(RenderedTemplateNotificationHandler):
def __init__(self, *, template_path: Path, stream: TextIO | None = None) -> None:
super().__init__(
template_providers={"body": FileTemplateProvider(template_path)},
include_result_type=True,
)
self.stream = stream or sys.stdout

def notify(
self,
*,
result: Mapping[str, Any],
rendered: Mapping[str, str],
record: Mapping[str, Any],
) -> None:
print(rendered["body"], file=self.stream)

def render_payload(self, *, payload_json: str, result_type: str) -> None:
event = {
"Records": [
{
"body": payload_json,
"eventSource": "aws:sqs",
"messageAttributes": {
"result_type": {
"DataType": "String",
"StringValue": result_type,
}
},
}
]
}
self.lambda_handler(event=event, context=None)

Comment on lines +63 to +79

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

render_payload() builds a synthetic SQS record without messageId. In RenderedTemplateNotificationHandler.lambda_handler, exceptions are only converted into batchItemFailures when messageId is present; otherwise they are re-raised. This makes the CLI tool’s success/failure behavior depend on an artificial omission and diverge from real SQS events. Consider adding a dummy messageId and then checking the returned batchItemFailures (treat any failures as a non-zero exit), or bypass lambda_handler and call the parsing/rendering path in a way that deterministically raises on render failures.

Copilot uses AI. Check for mistakes.

def read_payload_json(source: str, *, stdin: TextIO) -> str:
if source == "-":
payload_json = stdin.read()
if not payload_json.strip():
raise ValueError("stdin did not contain JSON content")
return payload_json
return Path(source).read_text(encoding="utf-8")


def extract_result_payload(payload_json: str, *, result_type: str) -> str:
try:
payload = json.loads(payload_json)
except json.JSONDecodeError as exc:
raise ValueError("Task output must be valid JSON") from exc
if not isinstance(payload, dict):
raise ValueError("Task output must be a JSON object keyed by result type")
selected = payload.get(result_type)
if not isinstance(selected, dict):
raise ValueError(
f"Result payload for type '{result_type}' must be a JSON object"
)
return json.dumps(selected)


def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
payload_json = read_payload_json(args.output_json, stdin=sys.stdin)
payload_json = extract_result_payload(
payload_json, result_type=args.result_type
)
handler = RenderNotificationHandler(template_path=args.template)
handler.render_payload(payload_json=payload_json, result_type=args.result_type)
except (OSError, ValueError, TemplateError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
17 changes: 17 additions & 0 deletions tests/notifications/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from lambdacron.notifications.base import (
EnvVarTemplateProvider,
FileTemplateProvider,
RenderedTemplateNotificationHandler,
)

Expand Down Expand Up @@ -51,6 +52,22 @@ def test_env_var_template_provider_requires_value(monkeypatch):
provider.get_template()


def test_file_template_provider_reads_template(tmp_path):
template_path = tmp_path / "template.jinja2"
template_path.write_text("Hello {{ name }}", encoding="utf-8")

provider = FileTemplateProvider(template_path)

assert provider.get_template() == "Hello {{ name }}"


def test_file_template_provider_raises_for_missing_file(tmp_path):
provider = FileTemplateProvider(tmp_path / "missing.jinja2")

with pytest.raises(FileNotFoundError):
provider.get_template()


def test_notification_handler_parses_sqs_json_body(monkeypatch):
monkeypatch.setenv("TEMPLATE", "Status {{ status }}")
handler = CapturingHandler(template_providers={"body": EnvVarTemplateProvider()})
Expand Down
165 changes: 165 additions & 0 deletions tests/test_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import json
from io import StringIO

import pytest

from lambdacron import render


def test_render_main_renders_with_long_flags(tmp_path, capsys):
template_path = tmp_path / "template.jinja2"
output_path = tmp_path / "output.json"
template_path.write_text(
"Status {{ status }} ({{ result_type }})", encoding="utf-8"
)
output_path.write_text(json.dumps({"success": {"status": "ok"}}), encoding="utf-8")

code = render.main(
[
"--template",
str(template_path),
"--result-type",
"success",
str(output_path),
]
)

captured = capsys.readouterr()
assert code == 0
assert captured.out == "Status ok (success)\n"
assert captured.err == ""


def test_render_main_short_flags_preserve_payload_result_type(tmp_path, capsys):
template_path = tmp_path / "template.jinja2"
output_path = tmp_path / "output.json"
template_path.write_text("{{ result_type }}", encoding="utf-8")
output_path.write_text(
json.dumps({"attribute": {"status": "ok", "result_type": "payload"}}),
encoding="utf-8",
)

code = render.main(["-t", str(template_path), "-r", "attribute", str(output_path)])

captured = capsys.readouterr()
assert code == 0
assert captured.out == "payload\n"
assert captured.err == ""


def test_render_main_rejects_non_object_json(tmp_path, capsys):
template_path = tmp_path / "template.jinja2"
output_path = tmp_path / "output.json"
template_path.write_text("{{ value }}", encoding="utf-8")
output_path.write_text(json.dumps(["not", "an", "object"]), encoding="utf-8")

code = render.main(["-t", str(template_path), "-r", "success", str(output_path)])

captured = capsys.readouterr()
assert code == 1
assert "Task output must be a JSON object keyed by result type" in captured.err


def test_render_main_uses_strict_undefined(tmp_path, capsys):
template_path = tmp_path / "template.jinja2"
output_path = tmp_path / "output.json"
template_path.write_text("{{ missing }}", encoding="utf-8")
output_path.write_text(json.dumps({"success": {"status": "ok"}}), encoding="utf-8")

code = render.main(["-t", str(template_path), "-r", "success", str(output_path)])

captured = capsys.readouterr()
assert code == 1
assert "undefined" in captured.err.lower()


def test_render_main_requires_template_and_result_type(tmp_path):
output_path = tmp_path / "output.json"
output_path.write_text(json.dumps({"success": {"status": "ok"}}), encoding="utf-8")

with pytest.raises(SystemExit) as exc_info:
render.main([str(output_path)])

assert exc_info.value.code == 2


def test_render_main_reads_from_stdin_when_file_is_omitted(
tmp_path, capsys, monkeypatch
):
template_path = tmp_path / "template.jinja2"
template_path.write_text(
"Status {{ status }} ({{ result_type }})", encoding="utf-8"
)
monkeypatch.setattr(
"sys.stdin", StringIO(json.dumps({"success": {"status": "ok"}}))
)

code = render.main(["-t", str(template_path), "-r", "success"])

captured = capsys.readouterr()
assert code == 0
assert captured.out == "Status ok (success)\n"
assert captured.err == ""


def test_render_main_reads_from_stdin_with_dash(tmp_path, capsys, monkeypatch):
template_path = tmp_path / "template.jinja2"
template_path.write_text("{{ result_type }}", encoding="utf-8")
monkeypatch.setattr(
"sys.stdin", StringIO(json.dumps({"success": {"status": "ok"}}))
)

code = render.main(["-t", str(template_path), "-r", "success", "-"])

captured = capsys.readouterr()
assert code == 0
assert captured.out == "success\n"
assert captured.err == ""


def test_render_main_extracts_payload_from_result_map_stdin(
tmp_path, capsys, monkeypatch
):
template_path = tmp_path / "template.jinja2"
template_path.write_text(
"Status {{ status }} ({{ result_type }})", encoding="utf-8"
)
monkeypatch.setattr(
"sys.stdin",
StringIO(
json.dumps({"success": {"status": "ok"}, "failure": {"status": "bad"}})
),
)

code = render.main(["-t", str(template_path), "-r", "success"])

captured = capsys.readouterr()
assert code == 0
assert captured.out == "Status ok (success)\n"
assert captured.err == ""


def test_render_main_rejects_task_output_missing_result_type(tmp_path, capsys):
template_path = tmp_path / "template.jinja2"
output_path = tmp_path / "output.json"
template_path.write_text("{{ status }}", encoding="utf-8")
output_path.write_text(json.dumps({"failure": {"status": "bad"}}), encoding="utf-8")

code = render.main(["-t", str(template_path), "-r", "success", str(output_path)])

captured = capsys.readouterr()
assert code == 1
assert "Result payload for type 'success' must be a JSON object" in captured.err


def test_render_main_rejects_non_object_result_payload(tmp_path, capsys):
template_path = tmp_path / "template.jinja2"
output_path = tmp_path / "output.json"
template_path.write_text("{{ status }}", encoding="utf-8")
output_path.write_text(json.dumps({"success": "ok"}), encoding="utf-8")

code = render.main(["-t", str(template_path), "-r", "success", str(output_path)])

captured = capsys.readouterr()
assert code == 1
assert "Result payload for type 'success' must be a JSON object" in captured.err