Coverage for .tox/coverage/lib/python3.13/site-packages/wuttjamaican/diffs.py: 100%
92 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-24 16:26 -0500
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-24 16:26 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2025 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Tools for displaying simple data diffs
25"""
27from mako.template import Template
28from webhelpers2.html import HTML
31class Diff: # pylint: disable=too-many-instance-attributes
32 """
33 Represent / display a basic "diff" between two data records.
35 You must provide both the "old" and "new" data records, when
36 constructing an instance of this class. Then call
37 :meth:`render_html()` to display the diff table.
39 :param config: The app :term:`config object`.
41 :param old_data: Dict of "old" data record.
43 :param new_data: Dict of "new" data record.
45 :param fields: Optional list of field names. If not specified,
46 will be derived from the data records.
48 :param columns: Optional column names for the table. Default
49 columns are: ``["field name", "old value", "new value"]``
51 :param nature: What sort of diff is being represented; must be one
52 of: ``("create", "update", "delete")``
54 :param repr_values: Boolean indicating the cell values should be
55 ran through :func:`python:repr()` before rendering. Default is
56 ``True``.
58 :param monospace_values: Boolean indicating the cell values should
59 be rendered with monospace font style. Default is ``True``.
61 :param old_color: Background color to display for "old/deleted"
62 field data, when applicable.
64 :param new_color: Background color to display for "new/created"
65 field data, when applicable.
67 :param cell_padding: Optional override for cell padding style.
69 :param extra_row_attrs: Optional extra attributes to be applied to
70 the table rows. See :attr:`extra_row_attrs` below.
72 .. attribute:: extra_row_attrs
74 Optional extra attributes to be applied to the table rows.
76 This is normally set via the constructor. It is checked by
77 :meth:`get_row_attrs()` when collecting the final attributes
78 for a row.
80 If a dict is specified here, the attributes are applied to
81 *all* rows.
83 If a callable is specified, it is called for each row to
84 determine which attributes to apply. The callable signature is
85 ``(field, is_diff, attrs)`` with params meaning:
87 * ``field`` - name of the current row's field
88 * ``is_diff`` - whether the row field has a diff
89 * ``attrs`` dict of row attrs collected so far
91 The callable should return a dict, which is then merged with
92 attributes already collected.
93 """
95 cell_padding = "0.25rem"
97 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
98 self,
99 config,
100 old_data: dict,
101 new_data: dict,
102 fields: list = None,
103 columns=None,
104 nature="update",
105 repr_values=True,
106 monospace_values=True,
107 old_color="#ffebe9",
108 new_color="#dafbe1",
109 cell_padding=None,
110 extra_row_attrs=None,
111 ):
112 self.config = config
113 self.app = self.config.get_app()
114 self.old_data = old_data
115 self.new_data = new_data
116 self.columns = columns or ["field name", "old value", "new value"]
117 self.fields = fields or self.make_fields()
118 self.nature = nature
119 self.repr_values = repr_values
120 self.monospace_values = monospace_values
121 self.old_color = old_color
122 self.new_color = new_color
123 if cell_padding:
124 self.cell_padding = cell_padding
125 self.extra_row_attrs = extra_row_attrs
127 def make_fields(self): # pylint: disable=missing-function-docstring
128 if self.old_data and self.new_data:
129 fields = set(self.old_data) & set(self.new_data)
130 else:
131 fields = set(self.old_data or self.new_data)
132 return sorted(fields, key=lambda f: f.lower())
134 def render_html(self, template=None, **kwargs):
135 """
136 Render the diff as HTML table.
138 :param template: Name of template to render, if you need to
139 override the default.
141 :param \\**kwargs: Remaining kwargs are passed as context to
142 the template renderer.
144 :returns: HTML literal string
145 """
146 context = kwargs
147 context["diff"] = self
149 if not isinstance(template, Template):
150 path = self.app.resource_path(
151 template or "wuttjamaican:templates/diff.mako"
152 )
153 template = Template(filename=path)
155 return HTML.literal(template.render(**context))
157 def render_field_row(self, field):
158 """
159 Render a complete ``<tr>`` element for the given field.
161 This calls other methods in pursuit of the final HTML:
163 * :meth:`get_row_attrs()`
164 * :meth:`get_old_value_attrs()`
165 * :meth:`get_new_value_attrs()`
166 * :meth:`render_old_value()`
167 * :meth:`render_new_value()`
169 :param field: Name of the field to render.
171 :returns: HTML literal ``<tr>`` element
172 """
173 is_diff = self.values_differ(field)
175 kw = {}
176 if self.cell_padding:
177 kw["style"] = f"padding: {self.cell_padding}"
178 td_field = HTML.tag("td", class_="field", c=field, **kw)
180 td_old_value = HTML.tag(
181 "td",
182 c=self.render_old_value(field),
183 **self.get_old_value_attrs(is_diff),
184 )
186 td_new_value = HTML.tag(
187 "td",
188 c=self.render_new_value(field),
189 **self.get_new_value_attrs(is_diff),
190 )
192 attrs = self.get_row_attrs(field, is_diff)
193 return HTML.tag("tr", c=[td_field, td_old_value, td_new_value], **attrs)
195 def get_row_attrs(self, field, is_diff=False):
196 """
197 Collect the HTML attributes for a ``<tr>`` element.
199 The default logic here does not declare any attributes.
200 However it does merge whatever comes from
201 :attr:`extra_row_attrs` (if set) for the final attributes.
203 :param field: Name of the field for the row.
205 :param is_diff: Boolean indicating whether the row field has a
206 diff.
208 :returns: HTML attributes as dict
209 """
210 attrs = {}
212 if callable(self.extra_row_attrs):
213 attrs = self.extra_row_attrs(field, is_diff, attrs) or {}
214 elif self.extra_row_attrs:
215 attrs.update(self.extra_row_attrs)
217 return attrs
219 def render_cell_value(self, value): # pylint: disable=missing-function-docstring
220 kw = {}
221 if self.repr_values:
222 value = repr(value)
223 if self.monospace_values:
224 kw["style"] = "font-family: monospace;"
225 return HTML.tag("span", c=[value], **kw)
227 def render_old_value(self, field): # pylint: disable=missing-function-docstring
228 if self.nature == "create":
229 value = ""
230 else:
231 value = self.old_value(field)
232 return self.render_cell_value(value)
234 def render_new_value(self, field): # pylint: disable=missing-function-docstring
235 if self.nature == "delete":
236 value = ""
237 else:
238 value = self.new_value(field)
239 return self.render_cell_value(value)
241 def get_cell_attrs( # pylint: disable=missing-function-docstring
242 self, style=None, **attrs
243 ):
244 style = dict(style or {})
246 if self.cell_padding and "padding" not in style:
247 style["padding"] = self.cell_padding
249 if style:
250 attrs["style"] = "; ".join([f"{k}: {v}" for k, v in style.items()])
252 return attrs
254 def get_old_value_attrs( # pylint: disable=missing-function-docstring
255 self, is_diff
256 ):
257 style = {}
258 if self.nature == "update" and is_diff:
259 style["background-color"] = self.old_color
260 elif self.nature == "delete":
261 style["background-color"] = self.old_color
263 return self.get_cell_attrs(style)
265 def get_new_value_attrs( # pylint: disable=missing-function-docstring
266 self, is_diff
267 ):
268 style = {}
269 if self.nature == "create":
270 style["background-color"] = self.new_color
271 elif self.nature == "update" and is_diff:
272 style["background-color"] = self.new_color
274 return self.get_cell_attrs(style)
276 def old_value(self, field): # pylint: disable=missing-function-docstring
277 return self.old_data.get(field)
279 def new_value(self, field): # pylint: disable=missing-function-docstring
280 return self.new_data.get(field)
282 def values_differ(self, field): # pylint: disable=missing-function-docstring
283 return self.new_value(field) != self.old_value(field)