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

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""" 

26 

27from mako.template import Template 

28from webhelpers2.html import HTML 

29 

30 

31class Diff: # pylint: disable=too-many-instance-attributes 

32 """ 

33 Represent / display a basic "diff" between two data records. 

34 

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. 

38 

39 :param config: The app :term:`config object`. 

40 

41 :param old_data: Dict of "old" data record. 

42 

43 :param new_data: Dict of "new" data record. 

44 

45 :param fields: Optional list of field names. If not specified, 

46 will be derived from the data records. 

47 

48 :param columns: Optional column names for the table. Default 

49 columns are: ``["field name", "old value", "new value"]`` 

50 

51 :param nature: What sort of diff is being represented; must be one 

52 of: ``("create", "update", "delete")`` 

53 

54 :param repr_values: Boolean indicating the cell values should be 

55 ran through :func:`python:repr()` before rendering. Default is 

56 ``True``. 

57 

58 :param monospace_values: Boolean indicating the cell values should 

59 be rendered with monospace font style. Default is ``True``. 

60 

61 :param old_color: Background color to display for "old/deleted" 

62 field data, when applicable. 

63 

64 :param new_color: Background color to display for "new/created" 

65 field data, when applicable. 

66 

67 :param cell_padding: Optional override for cell padding style. 

68 

69 :param extra_row_attrs: Optional extra attributes to be applied to 

70 the table rows. See :attr:`extra_row_attrs` below. 

71 

72 .. attribute:: extra_row_attrs 

73 

74 Optional extra attributes to be applied to the table rows. 

75 

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. 

79 

80 If a dict is specified here, the attributes are applied to 

81 *all* rows. 

82 

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: 

86 

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 

90 

91 The callable should return a dict, which is then merged with 

92 attributes already collected. 

93 """ 

94 

95 cell_padding = "0.25rem" 

96 

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 

126 

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()) 

133 

134 def render_html(self, template=None, **kwargs): 

135 """ 

136 Render the diff as HTML table. 

137 

138 :param template: Name of template to render, if you need to 

139 override the default. 

140 

141 :param \\**kwargs: Remaining kwargs are passed as context to 

142 the template renderer. 

143 

144 :returns: HTML literal string 

145 """ 

146 context = kwargs 

147 context["diff"] = self 

148 

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) 

154 

155 return HTML.literal(template.render(**context)) 

156 

157 def render_field_row(self, field): 

158 """ 

159 Render a complete ``<tr>`` element for the given field. 

160 

161 This calls other methods in pursuit of the final HTML: 

162 

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()` 

168 

169 :param field: Name of the field to render. 

170 

171 :returns: HTML literal ``<tr>`` element 

172 """ 

173 is_diff = self.values_differ(field) 

174 

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) 

179 

180 td_old_value = HTML.tag( 

181 "td", 

182 c=self.render_old_value(field), 

183 **self.get_old_value_attrs(is_diff), 

184 ) 

185 

186 td_new_value = HTML.tag( 

187 "td", 

188 c=self.render_new_value(field), 

189 **self.get_new_value_attrs(is_diff), 

190 ) 

191 

192 attrs = self.get_row_attrs(field, is_diff) 

193 return HTML.tag("tr", c=[td_field, td_old_value, td_new_value], **attrs) 

194 

195 def get_row_attrs(self, field, is_diff=False): 

196 """ 

197 Collect the HTML attributes for a ``<tr>`` element. 

198 

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. 

202 

203 :param field: Name of the field for the row. 

204 

205 :param is_diff: Boolean indicating whether the row field has a 

206 diff. 

207 

208 :returns: HTML attributes as dict 

209 """ 

210 attrs = {} 

211 

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) 

216 

217 return attrs 

218 

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) 

226 

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) 

233 

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) 

240 

241 def get_cell_attrs( # pylint: disable=missing-function-docstring 

242 self, style=None, **attrs 

243 ): 

244 style = dict(style or {}) 

245 

246 if self.cell_padding and "padding" not in style: 

247 style["padding"] = self.cell_padding 

248 

249 if style: 

250 attrs["style"] = "; ".join([f"{k}: {v}" for k, v in style.items()]) 

251 

252 return attrs 

253 

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 

262 

263 return self.get_cell_attrs(style) 

264 

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 

273 

274 return self.get_cell_attrs(style) 

275 

276 def old_value(self, field): # pylint: disable=missing-function-docstring 

277 return self.old_data.get(field) 

278 

279 def new_value(self, field): # pylint: disable=missing-function-docstring 

280 return self.new_data.get(field) 

281 

282 def values_differ(self, field): # pylint: disable=missing-function-docstring 

283 return self.new_value(field) != self.old_value(field)