feat: add simple Diff class, to render common table
This commit is contained in:
parent
78600c8cc2
commit
cca34bca1f
6 changed files with 353 additions and 0 deletions
6
docs/api/wuttjamaican.diffs.rst
Normal file
6
docs/api/wuttjamaican.diffs.rst
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttjamaican.diffs``
|
||||||
|
======================
|
||||||
|
|
||||||
|
.. automodule:: wuttjamaican.diffs
|
||||||
|
:members:
|
||||||
|
|
@ -90,6 +90,7 @@ Contents
|
||||||
api/wuttjamaican.db.model.upgrades
|
api/wuttjamaican.db.model.upgrades
|
||||||
api/wuttjamaican.db.sess
|
api/wuttjamaican.db.sess
|
||||||
api/wuttjamaican.db.util
|
api/wuttjamaican.db.util
|
||||||
|
api/wuttjamaican.diffs
|
||||||
api/wuttjamaican.email
|
api/wuttjamaican.email
|
||||||
api/wuttjamaican.enum
|
api/wuttjamaican.enum
|
||||||
api/wuttjamaican.exc
|
api/wuttjamaican.exc
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ dependencies = [
|
||||||
"python-configuration",
|
"python-configuration",
|
||||||
"typer",
|
"typer",
|
||||||
"uuid7",
|
"uuid7",
|
||||||
|
"WebHelpers2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
186
src/wuttjamaican/diffs.py
Normal file
186
src/wuttjamaican/diffs.py
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
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)
|
||||||
15
src/wuttjamaican/templates/diff.mako
Normal file
15
src/wuttjamaican/templates/diff.mako
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
<table border="1" style="border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
% for column in diff.columns:
|
||||||
|
<th style="padding: 0.25rem;">${column}</th>
|
||||||
|
% endfor
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
% for field in diff.fields:
|
||||||
|
${diff.render_field_row(field)}
|
||||||
|
% endfor
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
144
tests/test_diffs.py
Normal file
144
tests/test_diffs.py
Normal file
|
|
@ -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"),
|
||||||
|
'<span style="font-family: monospace;">'bar'</span>',
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
diff.render_new_value("foo"),
|
||||||
|
'<span style="font-family: monospace;">'baz'</span>',
|
||||||
|
)
|
||||||
|
|
||||||
|
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("<tr>", 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("</tr>", 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("<table", html)
|
||||||
|
self.assertIn("<tr>", 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("</tr>", html)
|
||||||
|
self.assertIn("</table>", html)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue