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:
+ | ${column} |
+ % endfor
+
+
+
+ % for field in diff.fields:
+ ${diff.render_field_row(field)}
+ % endfor
+
+
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)