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)