From 78600c8cc27c34bb7d2d833469f91dfe9c30a5f2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Dec 2025 17:20:44 -0600 Subject: [PATCH 1/4] fix: include thousands separator for `app.render_quantity()` --- src/wuttjamaican/app.py | 4 ++-- tests/test_app.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 044331f..3b7ef86 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -962,8 +962,8 @@ class AppHandler: # pylint: disable=too-many-public-methods value = int(value) if empty_zero and value == 0: return "" - return str(value) - return str(value).rstrip("0") + return f"{value:,}" + return f"{value:,}".rstrip("0") def render_time_ago(self, value): """ diff --git a/tests/test_app.py b/tests/test_app.py index cd2236b..7bdebf9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -672,6 +672,14 @@ app_title = WuttaTest self.assertEqual(self.app.render_quantity(0), "0") self.assertEqual(self.app.render_quantity(0, empty_zero=True), "") + # has thousands separator + value = 1234 + self.assertEqual(self.app.render_quantity(value), "1,234") + value = decimal.Decimal("1234.567") + self.assertEqual(self.app.render_quantity(value), "1,234.567") + value = decimal.Decimal("1234.567000") + self.assertEqual(self.app.render_quantity(value), "1,234.567") + def test_render_time_ago(self): with patch.object(mod, "humanize") as humanize: humanize.naturaltime.return_value = "now" From cca34bca1f06f950cb70ef36b0ce413525c614d7 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Dec 2025 18:05:48 -0600 Subject: [PATCH 2/4] feat: add simple Diff class, to render common table --- docs/api/wuttjamaican.diffs.rst | 6 + docs/index.rst | 1 + pyproject.toml | 1 + src/wuttjamaican/diffs.py | 186 +++++++++++++++++++++++++++ src/wuttjamaican/templates/diff.mako | 15 +++ tests/test_diffs.py | 144 +++++++++++++++++++++ 6 files changed, 353 insertions(+) create mode 100644 docs/api/wuttjamaican.diffs.rst create mode 100644 src/wuttjamaican/diffs.py create mode 100644 src/wuttjamaican/templates/diff.mako create mode 100644 tests/test_diffs.py diff --git a/docs/api/wuttjamaican.diffs.rst b/docs/api/wuttjamaican.diffs.rst new file mode 100644 index 0000000..716b0c1 --- /dev/null +++ b/docs/api/wuttjamaican.diffs.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.diffs`` +====================== + +.. automodule:: wuttjamaican.diffs + :members: diff --git a/docs/index.rst b/docs/index.rst index e2ccb8a..f61d77d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -90,6 +90,7 @@ Contents api/wuttjamaican.db.model.upgrades api/wuttjamaican.db.sess api/wuttjamaican.db.util + api/wuttjamaican.diffs api/wuttjamaican.email api/wuttjamaican.enum api/wuttjamaican.exc diff --git a/pyproject.toml b/pyproject.toml index 2e48e86..14d965b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "python-configuration", "typer", "uuid7", + "WebHelpers2", ] diff --git a/src/wuttjamaican/diffs.py b/src/wuttjamaican/diffs.py new file mode 100644 index 0000000..38e4214 --- /dev/null +++ b/src/wuttjamaican/diffs.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2025 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +Tools for displaying simple data diffs +""" + +from mako.template import Template +from webhelpers2.html import HTML + + +class Diff: # pylint: disable=too-many-instance-attributes + """ + Represent / display a basic "diff" between two data records. + + You must provide both the "old" and "new" data records, when + constructing an instance of this class. Then call + :meth:`render_html()` to display the diff table. + + :param config: The app :term:`config object`. + + :param old_data: Dict of "old" data record. + + :param new_data: Dict of "new" data record. + + :param fields: Optional list of field names. If not specified, + will be derived from the data records. + + :param nature: What sort of diff is being represented; must be one + of: ``("create", "update", "delete")`` + + :param old_color: Background color to display for "old/deleted" + field data, when applicable. + + :param new_color: Background color to display for "new/created" + field data, when applicable. + + :param cell_padding: Optional override for cell padding style. + """ + + cell_padding = "0.25rem" + + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + config, + old_data: dict, + new_data: dict, + fields: list = None, + nature="update", + old_color="#ffebe9", + new_color="#dafbe1", + cell_padding=None, + ): + self.config = config + self.app = self.config.get_app() + self.old_data = old_data + self.new_data = new_data + self.columns = ["field name", "old value", "new value"] + self.fields = fields or self.make_fields() + self.nature = nature + self.old_color = old_color + self.new_color = new_color + if cell_padding: + self.cell_padding = cell_padding + + def make_fields(self): # pylint: disable=missing-function-docstring + return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower()) + + def render_html(self, template=None, **kwargs): + """ + Render the diff as HTML table. + + :param template: Name of template to render, if you need to + override the default. + + :param \\**kwargs: Remaining kwargs are passed as context to + the template renderer. + + :returns: HTML literal string + """ + context = kwargs + context["diff"] = self + + if not isinstance(template, Template): + path = self.app.resource_path( + template or "wuttjamaican:templates/diff.mako" + ) + template = Template(filename=path) + + return HTML.literal(template.render(**context)) + + def render_field_row(self, field): # pylint: disable=missing-function-docstring + is_diff = self.values_differ(field) + + kw = {} + if self.cell_padding: + kw["style"] = f"padding: {self.cell_padding}" + td_field = HTML.tag("td", class_="field", c=field, **kw) + + td_old_value = HTML.tag( + "td", + c=self.render_old_value(field), + **self.get_old_value_attrs(is_diff), + ) + + td_new_value = HTML.tag( + "td", + c=self.render_new_value(field), + **self.get_new_value_attrs(is_diff), + ) + + return HTML.tag("tr", c=[td_field, td_old_value, td_new_value]) + + def render_cell_value(self, value): # pylint: disable=missing-function-docstring + return HTML.tag("span", c=[value], style="font-family: monospace;") + + def render_old_value(self, field): # pylint: disable=missing-function-docstring + value = "" if self.nature == "create" else repr(self.old_value(field)) + return self.render_cell_value(value) + + def render_new_value(self, field): # pylint: disable=missing-function-docstring + value = "" if self.nature == "delete" else repr(self.new_value(field)) + return self.render_cell_value(value) + + def get_cell_attrs( # pylint: disable=missing-function-docstring + self, style=None, **attrs + ): + style = dict(style or {}) + + if self.cell_padding and "padding" not in style: + style["padding"] = self.cell_padding + + if style: + attrs["style"] = "; ".join([f"{k}: {v}" for k, v in style.items()]) + + return attrs + + def get_old_value_attrs( # pylint: disable=missing-function-docstring + self, is_diff + ): + style = {} + if self.nature == "update" and is_diff: + style["background-color"] = self.old_color + elif self.nature == "delete": + style["background-color"] = self.old_color + + return self.get_cell_attrs(style) + + def get_new_value_attrs( # pylint: disable=missing-function-docstring + self, is_diff + ): + style = {} + if self.nature == "create": + style["background-color"] = self.new_color + elif self.nature == "update" and is_diff: + style["background-color"] = self.new_color + + return self.get_cell_attrs(style) + + def old_value(self, field): # pylint: disable=missing-function-docstring + return self.old_data.get(field) + + def new_value(self, field): # pylint: disable=missing-function-docstring + return self.new_data.get(field) + + def values_differ(self, field): # pylint: disable=missing-function-docstring + return self.new_value(field) != self.old_value(field) diff --git a/src/wuttjamaican/templates/diff.mako b/src/wuttjamaican/templates/diff.mako new file mode 100644 index 0000000..977c1ef --- /dev/null +++ b/src/wuttjamaican/templates/diff.mako @@ -0,0 +1,15 @@ +## -*- coding: utf-8; -*- + + + + % for column in diff.columns: + + % endfor + + + + % for field in diff.fields: + ${diff.render_field_row(field)} + % endfor + +
${column}
diff --git a/tests/test_diffs.py b/tests/test_diffs.py new file mode 100644 index 0000000..32dedeb --- /dev/null +++ b/tests/test_diffs.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8; -*- + +from wuttjamaican import diffs as mod +from wuttjamaican.testing import ConfigTestCase + + +class TestDiff(ConfigTestCase): + + def make_diff(self, *args, **kwargs): + return mod.Diff(self.config, *args, **kwargs) + + def test_constructor(self): + old_data = {"foo": "bar"} + new_data = {"foo": "baz"} + diff = self.make_diff(old_data, new_data, fields=["foo"]) + self.assertEqual(diff.fields, ["foo"]) + self.assertEqual(diff.cell_padding, "0.25rem") + diff = self.make_diff(old_data, new_data, cell_padding="0.5rem") + self.assertEqual(diff.cell_padding, "0.5rem") + + def test_make_fields(self): + old_data = {"foo": "bar"} + new_data = {"foo": "bar", "baz": "zer"} + # nb. this calls make_fields() + diff = self.make_diff(old_data, new_data) + # TODO: should the fields be cumulative? or just use new_data? + self.assertEqual(diff.fields, ["baz", "foo"]) + + def test_values(self): + old_data = {"foo": "bar"} + new_data = {"foo": "baz"} + diff = self.make_diff(old_data, new_data) + self.assertEqual(diff.old_value("foo"), "bar") + self.assertEqual(diff.new_value("foo"), "baz") + + def test_values_differ(self): + old_data = {"foo": "bar"} + new_data = {"foo": "baz"} + diff = self.make_diff(old_data, new_data) + self.assertTrue(diff.values_differ("foo")) + + old_data = {"foo": "bar"} + new_data = {"foo": "bar"} + diff = self.make_diff(old_data, new_data) + self.assertFalse(diff.values_differ("foo")) + + def test_render_values(self): + old_data = {"foo": "bar"} + new_data = {"foo": "baz"} + diff = self.make_diff(old_data, new_data) + self.assertEqual( + diff.render_old_value("foo"), + ''bar'', + ) + self.assertEqual( + diff.render_new_value("foo"), + ''baz'', + ) + + def test_get_old_value_attrs(self): + + # no change + old_data = {"foo": "bar"} + new_data = {"foo": "bar"} + diff = self.make_diff(old_data, new_data, nature="update") + self.assertEqual(diff.get_old_value_attrs(False), {"style": "padding: 0.25rem"}) + + # update + old_data = {"foo": "bar"} + new_data = {"foo": "baz"} + diff = self.make_diff(old_data, new_data, nature="update") + self.assertEqual( + diff.get_old_value_attrs(True), + {"style": f"background-color: {diff.old_color}; padding: 0.25rem"}, + ) + + # delete + old_data = {"foo": "bar"} + new_data = {} + diff = self.make_diff(old_data, new_data, nature="delete") + self.assertEqual( + diff.get_old_value_attrs(True), + {"style": f"background-color: {diff.old_color}; padding: 0.25rem"}, + ) + + def test_get_new_value_attrs(self): + + # no change + old_data = {"foo": "bar"} + new_data = {"foo": "bar"} + diff = self.make_diff(old_data, new_data, nature="update") + self.assertEqual(diff.get_new_value_attrs(False), {"style": "padding: 0.25rem"}) + + # update + old_data = {"foo": "bar"} + new_data = {"foo": "baz"} + diff = self.make_diff(old_data, new_data, nature="update") + self.assertEqual( + diff.get_new_value_attrs(True), + {"style": f"background-color: {diff.new_color}; padding: 0.25rem"}, + ) + + # create + old_data = {} + new_data = {"foo": "bar"} + diff = self.make_diff(old_data, new_data, nature="create") + self.assertEqual( + diff.get_new_value_attrs(True), + {"style": f"background-color: {diff.new_color}; padding: 0.25rem"}, + ) + + def test_render_field_row(self): + old_data = {"foo": "bar"} + new_data = {"foo": "baz"} + diff = self.make_diff(old_data, new_data) + row = diff.render_field_row("foo") + self.assertIn("", row) + self.assertIn("'bar'", row) + self.assertIn( + f'style="background-color: {diff.old_color}; padding: 0.25rem"', row + ) + self.assertIn("'baz'", row) + self.assertIn( + f'style="background-color: {diff.new_color}; padding: 0.25rem"', row + ) + self.assertIn("", row) + + def test_render_html(self): + old_data = {"foo": "bar"} + new_data = {"foo": "baz"} + diff = self.make_diff(old_data, new_data) + html = diff.render_html() + self.assertIn("", html) + self.assertIn("'bar'", html) + self.assertIn( + f'style="background-color: {diff.old_color}; padding: 0.25rem"', html + ) + self.assertIn("'baz'", html) + self.assertIn( + f'style="background-color: {diff.new_color}; padding: 0.25rem"', html + ) + self.assertIn("", html) + self.assertIn("", html) From 2a9ace2a3888eb41d1f46ed1d1925577ab767ffe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 19 Dec 2025 21:16:43 -0600 Subject: [PATCH 3/4] fix: add `fallback_key` support for email settings sometimes (e.g. for "import/export warning") we need some common template and/or config --- docs/glossary.rst | 9 +- docs/narr/email/sending.rst | 4 +- src/wuttjamaican/email.py | 212 +++++++++++++++++++++++++++++++----- tests/test_email.py | 72 +++++++++--- 4 files changed, 250 insertions(+), 47 deletions(-) diff --git a/docs/glossary.rst b/docs/glossary.rst index 6550ea0..0218540 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -203,10 +203,11 @@ Glossary setting` definitions. email setting - This refers to the settings for a particular :term:`email type`, - i.e. its sender and recipients, subject etc. So each email type - has a "collection" of settings, and that collection is referred - to simply as an "email setting" in the singular. + This refers to the :term:`config settings ` for a + particular :term:`email type`, i.e. its sender and recipients, + subject etc. So each email type has a "collection" of settings, + and that collection is referred to simply as an "email setting" + in the singular. email template Usually this refers to the HTML or TXT template file, used to diff --git a/docs/narr/email/sending.rst b/docs/narr/email/sending.rst index 31b76db..fb09fd4 100644 --- a/docs/narr/email/sending.rst +++ b/docs/narr/email/sending.rst @@ -10,8 +10,8 @@ Basics To send an email you (usually) need 3 things: -* key - unique key identifying the type of email -* template - template file to render message body +* key - unique key identifying the :term:`email type` +* template - :term:`email template` file to render message body * context - context dict for template file rendering And actually the template just needs to exist somewhere it can be diff --git a/src/wuttjamaican/email.py b/src/wuttjamaican/email.py index f752dc7..bbe1be7 100644 --- a/src/wuttjamaican/email.py +++ b/src/wuttjamaican/email.py @@ -23,8 +23,10 @@ """ Email Handler """ +# pylint: disable=too-many-lines import logging +import re import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -60,6 +62,7 @@ class EmailSetting: # pylint: disable=too-few-public-methods default_subject = "Something happened!" + # nb. this is not used for sending; only preview def sample_data(self): return { 'foo': 1234, @@ -84,23 +87,80 @@ class EmailSetting: # pylint: disable=too-few-public-methods * allows for hard-coded sample context which can be used to render templates for preview + .. attribute:: key + + Unique identifier for this :term:`email type`. + + This is the :term:`email key` used for config/template lookup, + e.g. when sending an email. + + This is automatically set based on the *class name* so there is + no need (or point) to set it. But the attribute is here for + read access, for convenience / code readability:: + + class poser_alert_foo(EmailSetting): + default_subject = "Something happened!" + + handler = app.get_email_handler() + setting = handler.get_email_setting("poser_alert_foo") + assert setting.key == "poser_alert_foo" + + See also :attr:`fallback_key`. + .. attribute:: default_subject - Default :attr:`Message.subject` for the email, if none is - configured. + Default subject for sending emails of this type. + + Usually, if config does not override, this will become + :attr:`Message.subject`. This is technically a Mako template string, so it will be rendered with the email context. But in most cases that feature can be ignored, and this will be a simple string. + + Calling code should not access this directly, but instead use + :meth:`get_default_subject()` . """ default_subject = None + fallback_key = None + """ + Optional fallback key to use for config/template lookup, if + nothing is found for :attr:`key`. + """ + def __init__(self, config): self.config = config self.app = config.get_app() self.key = self.__class__.__name__ + def get_description(self): + """ + This must return the full description for the :term:`email + type`. It is not used for the sending of email; only for + settings administration. + + Default logic will use the class docstring. + + :returns: String description for the email type + """ + return self.__class__.__doc__.strip() + + def get_default_subject(self): + """ + This must return the default subject, for sending emails of + this type. + + If config does not override, this will become + :attr:`Message.subject`. + + Default logic here returns :attr:`default_subject` as-is. + + :returns: Default subject as string + """ + return self.default_subject + def sample_data(self): """ Should return a dict with sample context needed to render the @@ -325,13 +385,18 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods """ if "email_settings" not in self.classes: self.classes["email_settings"] = {} + + # nb. we only want lower_case_names - all UpperCaseNames + # are assumed to be base classes + pattern = re.compile(r"^[a-z]") + for module in self.get_email_modules(): for name in dir(module): obj = getattr(module, name) if ( isinstance(obj, type) - and obj is not EmailSetting and issubclass(obj, EmailSetting) + and pattern.match(obj.__name__) ): self.classes["email_settings"][obj.__name__] = obj @@ -368,7 +433,9 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods """ return Message(**kwargs) - def make_auto_message(self, key, context=None, default_subject=None, **kwargs): + def make_auto_message( + self, key, context=None, default_subject=None, fallback_key=None, **kwargs + ): """ Make a new email message using config to determine its properties, and auto-generating body from a template. @@ -379,7 +446,8 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param key: Unique key for this particular "type" of message. This key is used as a prefix for all config settings and - template names pertinent to the message. + template names pertinent to the message. See also the + ``fallback_key`` param, below. :param context: Context dict used to render template(s) for the message. @@ -387,6 +455,10 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param default_subject: Optional :attr:`~Message.subject` template/string to use, if config does not specify one. + :param fallback_key: Optional fallback :term:`email key` to + use for config/template lookup, if nothing is found for + ``key``. + :param \\**kwargs: Any remaining kwargs are passed as-is to :meth:`make_message()`. More on this below. @@ -411,7 +483,7 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods kwargs["sender"] = self.get_auto_sender(key) if "subject" not in kwargs: kwargs["subject"] = self.get_auto_subject( - key, context, default=default_subject + key, context, default=default_subject, fallback_key=fallback_key ) if "to" not in kwargs: kwargs["to"] = self.get_auto_to(key) @@ -420,11 +492,47 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods if "bcc" not in kwargs: kwargs["bcc"] = self.get_auto_bcc(key) if "txt_body" not in kwargs: - kwargs["txt_body"] = self.get_auto_txt_body(key, context) + kwargs["txt_body"] = self.get_auto_txt_body( + key, context, fallback_key=fallback_key + ) if "html_body" not in kwargs: - kwargs["html_body"] = self.get_auto_html_body(key, context) + kwargs["html_body"] = self.get_auto_html_body( + key, context, fallback_key=fallback_key + ) return self.make_message(**kwargs) + def get_email_context(self, key, context=None): # pylint: disable=unused-argument + """ + This must return the "full" context for rendering the email + subject and/or body templates. + + Normally the input ``context`` is coming from the + :meth:`send_email()` param of the same name. + + By default, this method modifies the input context to add the + following: + + * ``config`` - reference to the :term:`config object` + * ``app`` - reference to the :term:`app handler` + + Subclass may further modify as needed. + + :param key: The :term:`email key` for which to get context. + + :param context: Input context dict. + + :returns: Final context dict + """ + if context is None: + context = {} + context.update( + { + "config": self.config, + "app": self.app, + } + ) + return context + def get_auto_sender(self, key): """ Returns automatic @@ -455,7 +563,13 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods return self.config.get(f"{self.config.appname}.email.default.replyto") def get_auto_subject( # pylint: disable=too-many-arguments,too-many-positional-arguments - self, key, context=None, rendered=True, setting=None, default=None + self, + key, + context=None, + rendered=True, + setting=None, + default=None, + fallback_key=None, ): """ Returns automatic :attr:`~wuttjamaican.email.Message.subject` @@ -464,7 +578,8 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods This calls :meth:`get_auto_subject_template()` and then (usually) renders the result using the given context. - :param key: Key for the :term:`email type`. + :param key: Key for the :term:`email type`. See also the + ``fallback_key`` param, below. :param context: Dict of context for rendering the subject template, if applicable. @@ -479,16 +594,23 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param default: Default subject to use if none is configured. + :param fallback_key: Optional fallback :term:`email key` to + use for config lookup, if nothing is found for ``key``. + :returns: Final subject text, either "raw" or rendered. """ - template = self.get_auto_subject_template(key, setting=setting, default=default) + template = self.get_auto_subject_template( + key, setting=setting, default=default, fallback_key=fallback_key + ) if not rendered: return template - context = context or {} + context = self.get_email_context(key, context) return Template(template).render(**context) - def get_auto_subject_template(self, key, setting=None, default=None): + def get_auto_subject_template( + self, key, setting=None, default=None, fallback_key=None + ): """ Returns the template string to use for automatic subject line of a message, as determined by config. @@ -509,22 +631,32 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param default: Default subject to use if none is configured. + :param fallback_key: Optional fallback :term:`email key` to + use for config lookup, if nothing is found for ``key``. + :returns: Final subject template, as raw text. """ # prefer configured subject specific to key - template = self.config.get(f"{self.config.appname}.email.{key}.subject") - if template: + if template := self.config.get(f"{self.config.appname}.email.{key}.subject"): return template # or use caller-specified default, if applicable if default: return default + # or use fallback key, if provided + if fallback_key: + if template := self.config.get( + f"{self.config.appname}.email.{fallback_key}.subject" + ): + return template + # or subject from email setting, if defined if not setting: setting = self.get_email_setting(key) - if setting and setting.default_subject: - return setting.default_subject + if setting: + if subject := setting.get_default_subject(): + return subject # fall back to global default return self.config.get( @@ -569,32 +701,34 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods f"{self.config.appname}.email.default.{typ}", default=[] ) - def get_auto_txt_body(self, key, context=None): + def get_auto_txt_body(self, key, context=None, fallback_key=None): """ Returns automatic :attr:`~wuttjamaican.email.Message.txt_body` content for a message, as determined by config. This renders a template with the given context. """ - template = self.get_auto_body_template(key, "txt") + template = self.get_auto_body_template(key, "txt", fallback_key=fallback_key) if template: - context = context or {} + context = self.get_email_context(key, context) return template.render(**context) return None - def get_auto_html_body(self, key, context=None): + def get_auto_html_body(self, key, context=None, fallback_key=None): """ Returns automatic :attr:`~wuttjamaican.email.Message.html_body` content for a message, as determined by config. This renders a template with the given context. """ - template = self.get_auto_body_template(key, "html") + template = self.get_auto_body_template(key, "html", fallback_key=fallback_key) if template: - context = context or {} + context = self.get_email_context(key, context) return template.render(**context) return None - def get_auto_body_template(self, key, mode): # pylint: disable=empty-docstring + def get_auto_body_template( # pylint: disable=empty-docstring + self, key, mode, fallback_key=None + ): """ """ mode = mode.lower() if mode == "txt": @@ -605,9 +739,19 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods raise ValueError("requested mode not supported") try: + + # prefer specific template for key return templates.get_template(f"{key}.{mode}.mako") + except TopLevelLookupException: - pass + + # but can use fallback if applicable + if fallback_key: + try: + return templates.get_template(f"{fallback_key}.{mode}.mako") + except TopLevelLookupException: + pass + return None def get_notes(self, key): @@ -756,7 +900,14 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods ) def send_email( # pylint: disable=too-many-arguments,too-many-positional-arguments - self, key=None, context=None, message=None, sender=None, recips=None, **kwargs + self, + key=None, + context=None, + message=None, + sender=None, + recips=None, + fallback_key=None, + **kwargs, ): """ Send an email message. @@ -773,6 +924,7 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods :param key: When auto-creating a message, this is the :term:`email key` identifying the type of email to send. Used to lookup config settings and template files. + See also the ``fallback_key`` param, below. :param context: Context dict for rendering automatic email template(s). @@ -812,6 +964,10 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods context = {'data': [1, 2, 3]} app.send_email('foo', context, to='me@example.com', cc='bobby@example.com') + :param fallback_key: Optional fallback :term:`email key` to + use for config/template lookup, if nothing is found for + ``key``. + :param \\**kwargs: Any remaining kwargs are passed along to :meth:`make_auto_message()`. So, not used if you provide the ``message``. @@ -827,7 +983,9 @@ class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods # auto-create message from key + context if sender: kwargs["sender"] = sender - message = self.make_auto_message(key, context or {}, **kwargs) + message = self.make_auto_message( + key, context or {}, fallback_key=fallback_key, **kwargs + ) if not (message.txt_body or message.html_body): raise RuntimeError( f"message (type: {key}) has no body - " diff --git a/tests/test_email.py b/tests/test_email.py index e4b6ff9..c9dca71 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -5,6 +5,7 @@ from unittest import TestCase from unittest.mock import patch, MagicMock import pytest +from mako.template import Template from wuttjamaican import email as mod from wuttjamaican.util import resource_path @@ -20,6 +21,16 @@ class TestEmailSetting(ConfigTestCase): self.assertIs(setting.app, self.app) self.assertEqual(setting.key, "EmailSetting") + def test_get_description(self): + + class MockSetting(mod.EmailSetting): + """ + this should be a good test + """ + + setting = MockSetting(self.config) + self.assertEqual(setting.get_description(), "this should be a good test") + def test_sample_data(self): setting = mod.EmailSetting(self.config) self.assertEqual(setting.sample_data(), {}) @@ -299,7 +310,9 @@ class TestEmailHandler(ConfigTestCase): msg = handler.make_auto_message("foo", subject=None) get_auto_subject.assert_not_called() msg = handler.make_auto_message("foo") - get_auto_subject.assert_called_once_with("foo", {}, default=None) + get_auto_subject.assert_called_once_with( + "foo", {}, default=None, fallback_key=None + ) # to with patch.object(handler, "get_auto_to") as get_auto_to: @@ -330,14 +343,18 @@ class TestEmailHandler(ConfigTestCase): msg = handler.make_auto_message("foo", txt_body=None) get_auto_txt_body.assert_not_called() msg = handler.make_auto_message("foo") - get_auto_txt_body.assert_called_once_with("foo", {}) + get_auto_txt_body.assert_called_once_with( + "foo", {"config": self.config, "app": self.app}, fallback_key=None + ) # html_body with patch.object(handler, "get_auto_html_body") as get_auto_html_body: msg = handler.make_auto_message("foo", html_body=None) get_auto_html_body.assert_not_called() msg = handler.make_auto_message("foo") - get_auto_html_body.assert_called_once_with("foo", {}) + get_auto_html_body.assert_called_once_with( + "foo", {"config": self.config, "app": self.app}, fallback_key=None + ) def test_get_auto_sender(self): handler = self.make_handler() @@ -384,6 +401,11 @@ class TestEmailHandler(ConfigTestCase): template = handler.get_auto_subject_template("foo") self.assertEqual(template, "Foo Message") + # can configure via fallback_key + self.config.setdefault("wutta.email.bar.subject", "Bar Message") + template = handler.get_auto_subject_template("baz", fallback_key="bar") + self.assertEqual(template, "Bar Message") + # EmailSetting can provide default subject providers = { "wuttatest": MagicMock(email_modules=["tests.test_email"]), @@ -446,26 +468,48 @@ class TestEmailHandler(ConfigTestCase): self.assertEqual(recips, ["bob@example.com"]) def test_get_auto_body_template(self): - from mako.template import Template - handler = self.make_handler() - # error if bad request - self.assertRaises(ValueError, handler.get_auto_body_template, "foo", "BADTYPE") + # error if invalid mode (must be 'html' or 'txt') + self.assertRaises(ValueError, handler.get_auto_body_template, "foo", "BAD_MODE") - # empty by default - template = handler.get_auto_body_template("foo", "txt") - self.assertIsNone(template) + # no template by default + self.assertIsNone(handler.get_auto_body_template("foo", "html")) + self.assertIsNone(handler.get_auto_body_template("foo", "txt")) - # but returns a template if it exists + # mock template lookup providers = { "wuttatest": MagicMock(email_templates=["tests:email-templates"]), } with patch.object(self.app, "providers", new=providers): handler = self.make_handler() - template = handler.get_auto_body_template("test_foo", "txt") - self.assertIsInstance(template, Template) - self.assertEqual(template.uri, "test_foo.txt.mako") + + # template exists (txt) + template = handler.get_auto_body_template("test_foo", "txt") + self.assertIsInstance(template, Template) + self.assertEqual(template.uri, "test_foo.txt.mako") + + # template exists (html) + template = handler.get_auto_body_template("test_foo", "html") + self.assertIsInstance(template, Template) + self.assertEqual(template.uri, "test_foo.html.mako") + + # no such template + template = handler.get_auto_body_template("no_such_template", "html") + self.assertIsNone(template) + + # but can use fallback + template = handler.get_auto_body_template( + "no_such_template", "html", fallback_key="test_foo" + ) + self.assertIsInstance(template, Template) + self.assertEqual(template.uri, "test_foo.html.mako") + + # what if fallback is also not found + template = handler.get_auto_body_template( + "no_such_template", "html", fallback_key="this_neither" + ) + self.assertIsNone(template) def test_get_auto_txt_body(self): handler = self.make_handler() From 5de9c15bbdc85d992af5ceba2aa73f5fca0ce70b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 20 Dec 2025 20:05:31 -0600 Subject: [PATCH 4/4] =?UTF-8?q?bump:=20version=200.26.0=20=E2=86=92=200.27?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983cc3d..9482ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to WuttJamaican will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.27.0 (2025-12-20) + +### Feat + +- add simple Diff class, to render common table + +### Fix + +- add `fallback_key` support for email settings +- include thousands separator for `app.render_quantity()` + ## v0.26.0 (2025-12-17) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 14d965b..3bd01e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.26.0" +version = "0.27.0" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]