code-prune 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
code_prune/reporter.py ADDED
@@ -0,0 +1,360 @@
1
+ """Report generation for code_prune: JSON and HTML output writers.
2
+
3
+ Author: Ron Webb
4
+ Since: 1.0.0
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html as _html
10
+ import json
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+
16
+ _DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
17
+
18
+ _CSS = """
19
+ * { box-sizing: border-box; margin: 0; padding: 0; }
20
+ body {
21
+ font-family: 'Segoe UI', Arial, sans-serif;
22
+ background: #f5f7fa;
23
+ color: #2c3e50;
24
+ padding: 2em;
25
+ }
26
+ header {
27
+ background: #1a3a5c;
28
+ color: #ffffff;
29
+ border-radius: 8px;
30
+ padding: 1.2em 1.8em;
31
+ margin-bottom: 1.5em;
32
+ }
33
+ header h1 { font-size: 1.6em; font-weight: 700; letter-spacing: 0.02em; }
34
+ header p { font-size: 0.9em; margin-top: 0.3em; opacity: 0.85; }
35
+ .summary {
36
+ font-size: 0.9em;
37
+ color: #555;
38
+ margin-bottom: 1em;
39
+ }
40
+ .table-wrap { overflow-x: auto; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
41
+ table {
42
+ border-collapse: collapse;
43
+ width: 100%;
44
+ background: #ffffff;
45
+ font-size: 0.88em;
46
+ }
47
+ thead tr { background: #1a3a5c; color: #ffffff; }
48
+ th {
49
+ padding: 0.75em 1em;
50
+ text-align: left;
51
+ font-weight: 600;
52
+ letter-spacing: 0.03em;
53
+ white-space: nowrap;
54
+ }
55
+ th.sortable { cursor: pointer; user-select: none; }
56
+ th.sortable:hover { background: #254e80; }
57
+ th.sortable .sort-icon { margin-left: 0.4em; font-size: 0.8em; }
58
+ th.sortable.asc .sort-icon::after { content: '\\25b2'; }
59
+ th.sortable.desc .sort-icon::after { content: '\\25bc'; }
60
+ th.sortable:not(.asc):not(.desc) .sort-icon::after { content: '\\21c5'; opacity: 0.6; }
61
+ .filter-row th { background: #1a3a5c; padding: 0.3em 1em 0.5em; }
62
+ .filter-row input {
63
+ width: 100%;
64
+ padding: 0.3em 0.5em;
65
+ border: 1px solid #3d6b9e;
66
+ border-radius: 4px;
67
+ background: #1e4575;
68
+ color: #fff;
69
+ font-size: 0.85em;
70
+ outline: none;
71
+ }
72
+ .filter-row input::placeholder { color: #aac4e8; }
73
+ .filter-row input:focus { border-color: #7eb3e8; background: #255085; }
74
+ td {
75
+ padding: 0.6em 1em;
76
+ border-bottom: 1px solid #e8ecf0;
77
+ vertical-align: top;
78
+ }
79
+ tbody tr.alt-row { background: #f0f4f8; }
80
+ tbody tr:hover { background: #dde9f7; }
81
+ td.row-num { text-align: right; color: #7f8c8d; font-variant-numeric: tabular-nums; width: 3em; }
82
+ td.category code {
83
+ background: #eef2f7;
84
+ border-radius: 3px;
85
+ padding: 0.1em 0.4em;
86
+ font-family: 'Consolas', 'Courier New', monospace;
87
+ font-size: 0.9em;
88
+ }
89
+ td.location { font-family: 'Consolas', 'Courier New', monospace; font-size: 0.85em; color: #555; }
90
+ td.name code { font-family: 'Consolas', 'Courier New', monospace; font-size: 0.9em; }
91
+ td.message { word-break: break-word; }
92
+ footer { margin-top: 1.5em; font-size: 0.8em; color: #aaa; text-align: center; }
93
+ """
94
+
95
+
96
+ def _write_json(
97
+ findings: list[dict[str, object]],
98
+ path: Path,
99
+ run_date: str,
100
+ paths_scanned: list[str],
101
+ categories: list[str],
102
+ ) -> None:
103
+ """Write findings as a JSON file.
104
+
105
+ Author: Ron Webb
106
+ Since: 1.0.0
107
+ """
108
+ report = {
109
+ "application": "code-prune",
110
+ "version": __version__,
111
+ "run-date": run_date,
112
+ "paths-scanned": paths_scanned,
113
+ "categories": categories,
114
+ "findings": findings,
115
+ }
116
+ path.write_text(json.dumps(report, indent=2), encoding="utf-8")
117
+
118
+
119
+ def _build_thead_html() -> str:
120
+ """Return the HTML ``<thead>`` element with sortable headers and a filter row.
121
+
122
+ Author: Ron Webb
123
+ Since: 1.0.0
124
+ """
125
+ return (
126
+ " <thead>\n"
127
+ " <tr>"
128
+ "<th>#</th>"
129
+ '<th class="sortable" onclick="codePruneSortBy(0)">Category<span class="sort-icon"></span></th>'
130
+ '<th class="sortable" onclick="codePruneSortBy(1)">Location<span class="sort-icon"></span></th>'
131
+ '<th class="sortable" onclick="codePruneSortBy(2)">Name<span class="sort-icon"></span></th>'
132
+ "<th>Message</th>"
133
+ "</tr>\n"
134
+ ' <tr class="filter-row">'
135
+ "<th></th>"
136
+ '<th><input type="text" id="filter-category" placeholder="Filter category\u2026"'
137
+ ' oninput="codePruneFilter()"></th>'
138
+ '<th><input type="text" id="filter-location" placeholder="Filter location\u2026"'
139
+ ' oninput="codePruneFilter()"></th>'
140
+ '<th><input type="text" id="filter-name" placeholder="Filter name\u2026"'
141
+ ' oninput="codePruneFilter()"></th>'
142
+ "<th></th>"
143
+ "</tr>\n"
144
+ " </thead>\n"
145
+ )
146
+
147
+
148
+ def _build_html_scaffold( # pylint: disable=too-many-arguments,too-many-positional-arguments
149
+ run_date: str,
150
+ total: int,
151
+ rows_html: str,
152
+ script: str,
153
+ paths_scanned: list[str],
154
+ categories: list[str],
155
+ ) -> str:
156
+ """Wrap the table body and script into a complete HTML document.
157
+
158
+ Author: Ron Webb
159
+ Since: 1.0.0
160
+ """
161
+ thead = _build_thead_html()
162
+ paths_html = "<br>".join(_html.escape(p, quote=False) for p in paths_scanned)
163
+ categories_html = (
164
+ ", ".join(_html.escape(c, quote=False) for c in categories) or "(none)"
165
+ )
166
+ return (
167
+ "<!DOCTYPE html>\n"
168
+ '<html lang="en">\n'
169
+ "<head>\n"
170
+ ' <meta charset="UTF-8">\n'
171
+ ' <meta name="viewport" content="width=device-width, initial-scale=1">\n'
172
+ f" <title>code-prune {__version__} Report</title>\n"
173
+ " <style>\n"
174
+ f"{_CSS}"
175
+ " </style>\n"
176
+ "</head>\n"
177
+ "<body>\n"
178
+ " <header>\n"
179
+ f" <h1>code-prune {__version__} Report</h1>\n"
180
+ f" <p>Date Run: {run_date}</p>\n"
181
+ f" <p>Paths scanned: {paths_html}</p>\n"
182
+ f" <p>Categories: {categories_html}</p>\n"
183
+ " </header>\n"
184
+ f' <p class="summary">Found {total} finding(s)</p>\n'
185
+ ' <div class="table-wrap">\n'
186
+ " <table>\n"
187
+ f"{thead}"
188
+ " <tbody>\n"
189
+ f"{rows_html}\n"
190
+ " </tbody>\n"
191
+ " </table>\n"
192
+ " </div>\n"
193
+ f" <footer>Generated by code-prune {__version__}</footer>\n"
194
+ f"{script}"
195
+ "</body>\n"
196
+ "</html>\n"
197
+ )
198
+
199
+
200
+ _SCRIPT = (
201
+ "<script>\n"
202
+ "(function () {\n"
203
+ " var sortCol = -1, sortDir = 1;\n"
204
+ "\n"
205
+ " function sortBy(col) {\n"
206
+ " if (sortCol === col) { sortDir = -sortDir; }\n"
207
+ " else { sortCol = col; sortDir = 1; }\n"
208
+ " applySort();\n"
209
+ " }\n"
210
+ "\n"
211
+ " function applySort() {\n"
212
+ " var tbody = document.querySelector('tbody');\n"
213
+ " var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));\n"
214
+ " var keys = ['category', 'location', 'name'];\n"
215
+ " rows.sort(function (a, b) {\n"
216
+ " if (sortCol < 0 || sortCol > 2) return 0;\n"
217
+ " var key = keys[sortCol];\n"
218
+ " var va = (a.dataset[key] || '').toLowerCase();\n"
219
+ " var vb = (b.dataset[key] || '').toLowerCase();\n"
220
+ " if (va < vb) return -sortDir;\n"
221
+ " if (va > vb) return sortDir;\n"
222
+ " return 0;\n"
223
+ " });\n"
224
+ " rows.forEach(function (r) { tbody.appendChild(r); });\n"
225
+ " updateNumbers();\n"
226
+ " restripe();\n"
227
+ " updateSortIcons();\n"
228
+ " }\n"
229
+ "\n"
230
+ " function updateSortIcons() {\n"
231
+ " var ths = document.querySelectorAll('thead tr:first-child th.sortable');\n"
232
+ " ths.forEach(function (th, i) {\n"
233
+ " th.classList.remove('asc', 'desc');\n"
234
+ " if (i === sortCol) { th.classList.add(sortDir === 1 ? 'asc' : 'desc'); }\n"
235
+ " });\n"
236
+ " }\n"
237
+ "\n"
238
+ " function applyFilter() {\n"
239
+ " var catFilter = document.getElementById('filter-category').value.toLowerCase();\n"
240
+ " var locFilter = document.getElementById('filter-location').value.toLowerCase();\n"
241
+ " var nameFilter = document.getElementById('filter-name').value.toLowerCase();\n"
242
+ " var tbody = document.querySelector('tbody');\n"
243
+ " tbody.querySelectorAll('tr').forEach(function (row) {\n"
244
+ " var catMatch = !catFilter || row.dataset.category.toLowerCase().indexOf(catFilter) !== -1;\n"
245
+ " var locMatch = !locFilter || row.dataset.location.toLowerCase().indexOf(locFilter) !== -1;\n"
246
+ " var nameMatch = !nameFilter || row.dataset.name.toLowerCase().indexOf(nameFilter) !== -1;\n"
247
+ " row.style.display = (catMatch && locMatch && nameMatch) ? '' : 'none';\n"
248
+ " });\n"
249
+ " updateNumbers();\n"
250
+ " restripe();\n"
251
+ " }\n"
252
+ "\n"
253
+ " function updateNumbers() {\n"
254
+ " var num = 1;\n"
255
+ " document.querySelectorAll('tbody tr').forEach(function (row) {\n"
256
+ " if (row.style.display !== 'none') {\n"
257
+ " row.querySelector('.row-num').textContent = num++;\n"
258
+ " }\n"
259
+ " });\n"
260
+ " }\n"
261
+ "\n"
262
+ " function restripe() {\n"
263
+ " var num = 0;\n"
264
+ " document.querySelectorAll('tbody tr').forEach(function (row) {\n"
265
+ " if (row.style.display !== 'none') {\n"
266
+ " num++;\n"
267
+ " row.classList.toggle('alt-row', num % 2 === 0);\n"
268
+ " }\n"
269
+ " });\n"
270
+ " }\n"
271
+ "\n"
272
+ " window.codePruneSortBy = sortBy;\n"
273
+ " window.codePruneFilter = applyFilter;\n"
274
+ "})();\n"
275
+ "</script>\n"
276
+ )
277
+
278
+
279
+ def _build_html(
280
+ findings: list[dict[str, object]],
281
+ run_date: str,
282
+ paths_scanned: list[str],
283
+ categories: list[str],
284
+ ) -> str:
285
+ """Build an HTML report string for the given findings.
286
+
287
+ Author: Ron Webb
288
+ Since: 1.0.0
289
+ """
290
+ rows: list[str] = []
291
+ for idx, finding in enumerate(findings, start=1):
292
+ category = str(finding["category"])
293
+ location = f"{finding['file']}:{finding['line']}:{finding['column']}"
294
+ name = str(finding["name"])
295
+ message = str(finding["message"])
296
+ alt = " alt-row" if idx % 2 == 0 else ""
297
+ rows.append(
298
+ f' <tr class="data-row{alt}"'
299
+ f' data-idx="{idx}"'
300
+ f' data-category="{_html.escape(category, quote=True)}"'
301
+ f' data-location="{_html.escape(location, quote=True)}"'
302
+ f' data-name="{_html.escape(name, quote=True)}">'
303
+ f'<td class="row-num">{idx}</td>'
304
+ f'<td class="category"><code>{_html.escape(category, quote=False)}</code></td>'
305
+ f'<td class="location">{_html.escape(location, quote=False)}</td>'
306
+ f'<td class="name"><code>{_html.escape(name, quote=False)}</code></td>'
307
+ f'<td class="message">{_html.escape(message, quote=False)}</td>'
308
+ f"</tr>"
309
+ )
310
+ rows_html = "\n".join(rows)
311
+ return _build_html_scaffold(
312
+ run_date, len(findings), rows_html, _SCRIPT, paths_scanned, categories
313
+ )
314
+
315
+
316
+ def _write_html(
317
+ findings: list[dict[str, object]],
318
+ path: Path,
319
+ run_date: str,
320
+ paths_scanned: list[str],
321
+ categories: list[str],
322
+ ) -> None:
323
+ """Write findings as an HTML report file.
324
+
325
+ Author: Ron Webb
326
+ Since: 1.0.0
327
+ """
328
+ path.write_text(
329
+ _build_html(findings, run_date, paths_scanned, categories), encoding="utf-8"
330
+ )
331
+
332
+
333
+ def write_outputs( # pylint: disable=too-many-arguments,too-many-positional-arguments
334
+ findings: list[dict[str, object]],
335
+ output_dir: Path,
336
+ stem: str,
337
+ fmt: str,
338
+ paths_scanned: list[str],
339
+ categories: list[str] | None = None,
340
+ ) -> list[Path]:
341
+ """Write output files according to the requested format.
342
+
343
+ Returns the list of paths written.
344
+
345
+ Author: Ron Webb
346
+ Since: 1.0.0
347
+ """
348
+ output_dir.mkdir(parents=True, exist_ok=True)
349
+ run_date = datetime.now().strftime(_DATE_FORMAT)
350
+ categories = categories or []
351
+ written: list[Path] = []
352
+ if fmt in ("json", "all"):
353
+ json_path = output_dir / f"{stem}.json"
354
+ _write_json(findings, json_path, run_date, paths_scanned, categories)
355
+ written.append(json_path)
356
+ if fmt in ("html", "all"):
357
+ html_path = output_dir / f"{stem}.html"
358
+ _write_html(findings, html_path, run_date, paths_scanned, categories)
359
+ written.append(html_path)
360
+ return written