-
Notifications
You must be signed in to change notification settings - Fork 2
Add template render tester #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,3 +12,8 @@ crash.log | |
| .vscode/ | ||
| __pycache__/ | ||
| .coverage | ||
|
|
||
| # Python packaging artifacts | ||
| *.egg | ||
| *.egg-info/ | ||
| .eggs/ | ||
| 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, | ||
| ) | ||
|
|
||
|
|
||
| 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
|
||
|
|
||
| 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()) | ||
| 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 |
There was a problem hiding this comment.
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, butsrc/lambdacron/is an implicit namespace package (no__init__.py). With the currentpyproject.tomlusingtool.setuptools.packages.find, top-level modules underlambdacron/(likerender.py) may not be included in built distributions unless namespace packages are explicitly enabled or a packagelambdacronis created. Please ensure the packaging config includes this module in wheels/sdists (e.g., enable namespace package discovery or addsrc/lambdacron/__init__.py).