doppia 0.1.0__tar.gz

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.
doppia-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026, Dave Vandenbout.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
doppia-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: doppia
3
+ Version: 0.1.0
4
+ Summary: Personal accounting for bank and credit card accounts, stored in CountAbout CSV format
5
+ Author: Dave Vandenbout
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/devbisme/doppia
8
+ Project-URL: Source, https://github.com/devbisme/doppia
9
+ Project-URL: Issues, https://github.com/devbisme/doppia/issues
10
+ Project-URL: Releases, https://github.com/devbisme/doppia/releases
11
+ Keywords: accounting,personal finance,ledger,countabout,bookkeeping
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: X11 Applications :: Qt
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: PySide6>=6.6
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # doppia
26
+
27
+ Personal accounting for bank and credit card accounts (PySide6 desktop GUI).
28
+ Data lives in a single CSV in CountAbout export format; files are written back
29
+ byte-for-byte compatible (unchanged rows are untouched, quoting matches CountAbout).
30
+
31
+ ## The name
32
+
33
+ doppia is named after *partita doppia*, Italian for double-entry bookkeeping. The method was
34
+ first described in print by Luca Pacioli (c. 1447–1517), an Italian mathematician and
35
+ Franciscan friar, in his *Summa de arithmetica, geometria, proportioni et proportionalita*
36
+ (Venice, 1494), which earned him the title "father of accounting".
37
+
38
+ ## Install
39
+
40
+ Install doppia from PyPI as a standalone app with
41
+ [pipx](https://pipx.pypa.io) or [uv](https://docs.astral.sh/uv/), which keep it isolated
42
+ without you having to manage a virtual environment:
43
+
44
+ pipx install doppia # or: uv tool install doppia
45
+
46
+ Python 3.10 or later is required. The clipboard import and **Guess with LLM** features also
47
+ need [Ollama](https://ollama.com) with a model installed, e.g. `ollama pull gemma4:12b-it-qat`;
48
+ everything else works without it.
49
+
50
+ ## Run
51
+
52
+ doppia path/to/countabout-export.csv # reopens the last file if omitted
53
+
54
+ ## Development
55
+
56
+ From a clone of the repository, [uv](https://docs.astral.sh/uv/) runs the app and tests
57
+ with the dependencies it needs:
58
+
59
+ uv run doppia path/to/countabout-export.csv
60
+ uv run --extra test pytest
61
+
62
+ ## Features
63
+
64
+ - Account list with today's balance and cleared balance. The bold **Total** row sums both
65
+ columns over the selected accounts (all accounts when none or "All accounts" is selected).
66
+ Future-dated rows shown grey.
67
+ - Money is shown as `$1,234.56`: red for negative, green for positive, no minus sign
68
+ (the minus is kept only in plain-text messages that can't be colored). Colors stay
69
+ visible on selected rows. `$` and commas are accepted when typing amounts.
70
+ - Register per account or all accounts: running balance, uncleared-only filter, and a
71
+ filter field that takes a case-insensitive regular expression (e.g. `^amazon`, `3000`,
72
+ `\$3,000`) matched in the description, category, memo, ref, date, or amount. Its X button
73
+ clears it; an invalid expression tints the field and is ignored. The account names over
74
+ the register are cut to 30 characters, including `...` (hover for the full list).
75
+ - Ctrl/Shift-click in the account list to select several accounts; the register shows their
76
+ combined transactions with a combined running balance. Reconcile and Rename account need
77
+ a single account selected.
78
+ - Click a column header to sort by it; click again to reverse. The sort column's header
79
+ is highlighted, and the selection is kept when re-sorting. The register opens sorted by
80
+ date, newest first. The Balance column is a date-order running balance, so it only
81
+ adds up when sorted by date.
82
+ - Click the Clr box to toggle cleared; cleared rows show a green ✔ (the CSV still stores `X`).
83
+ - Double-click a row to edit it; right-click for a menu to delete it.
84
+ - New/edit/delete (Ctrl+N / Ctrl+E / Ctrl+Del), Ctrl+K toggles cleared.
85
+ Payee memory: typing a known description fills its last category and amount.
86
+ - Search and replace (Transaction menu, Ctrl+H): find a regular expression in the
87
+ Description, Category, Memo, Ref, and/or Tags fields (optionally case-sensitive, optionally
88
+ only in the selected accounts). Check **Replace with** to preview each change (`\1` inserts a
89
+ matched group), untick any you don't want (**Select All/None** flips them all), then
90
+ **Replace checked**. Transfer/split
91
+ categories and results containing line breaks can't be replaced. Nothing is written to the
92
+ file until you save.
93
+ - Transfers use CountAbout's `{Account}` category; the mirror row in the other account
94
+ is created/updated/deleted automatically (matched by date and negated amount).
95
+ - Bank CSV import (Ctrl+I): column mapping saved as named profiles in
96
+ `~/.config/doppia/import_profiles.json`; payee/category matched from history;
97
+ duplicates (same amount within 3 days) unchecked by default; imported rows marked cleared.
98
+ - **Guess with LLM** asks the local model (the one chosen in clipboard import) which columns
99
+ hold the date, description, amount or debit/credit, memo, and check number, and the date
100
+ format. Only the header and first 8 rows are sent. Its answer is limited to the file's
101
+ real column names and checked against the sample rows; **Flip signs** is set from the
102
+ data (mostly positive amounts = card charges listed as positive), not by the model.
103
+ Takes a few seconds; save the result as a profile to skip it next time.
104
+ - Import from clipboard (File menu, Ctrl+Shift+V): copy transactions from a bank or card
105
+ website or statement, and a local LLM served by [Ollama](https://ollama.com) turns the text
106
+ into transactions for the chosen account (the active one by default). Nothing leaves your
107
+ machine. The text is shown for editing; press **Parse** (about 15–30 s on an RTX 3060).
108
+ - Model: any installed Ollama model, remembered between runs. Default `gemma4:12b-it-qat`,
109
+ which read sample bank and card text correctly; smaller models got dates and signs wrong.
110
+ Set `OLLAMA_HOST` if Ollama isn't at `localhost:11434`.
111
+ - Likely duplicates of existing transactions (same amount within 3 days) are removed from the
112
+ list; **Show likely duplicates** brings them back, unchecked. Payees and categories are
113
+ matched from history, as in CSV import.
114
+ - Review before importing: amounts the model returns that don't appear in the pasted text
115
+ are unchecked and flagged, and **Flip signs** fixes deposits/charges read backwards.
116
+ - Imported rows are marked cleared (optional) except ones shown as pending.
117
+ - Reconcile (Ctrl+R) against a statement ending balance: check off uncleared
118
+ transactions until the difference is $0.00.
119
+ - Reports (Ctrl+P): totals by category, category by month, income vs. expense by month.
120
+ Click a column header to sort.
121
+ - Transfers are excluded unless **Include transfers** is checked. Then each transfer is
122
+ reported under `Transfer: <other account>`. A transfer between two checked accounts appears
123
+ on both sides, so it adds to both income and expense while netting to zero.
124
+ - **Accounts All/None** checks or unchecks every account in the account list.
125
+ - **Categories** field: a case-insensitive regular expression matched anywhere in the
126
+ category name (e.g. `^(groceries|dining)$`). Press Enter or Run to apply; the X button
127
+ clears it and reruns. An invalid expression is reported and ignored.
128
+ - Ctrl/Shift-click category rows, then Run, to report only those categories. The
129
+ restriction persists across runs until **Show all categories** clears it (along with
130
+ the regex). Run uses whatever rows are highlighted when pressed.
131
+ - Total income, expense, and net rows reflect only the categories shown.
132
+ - **Save…** writes the report as shown (current sort order) to CSV (plain signed numbers for
133
+ spreadsheets), Markdown, plain text (aligned columns), or PDF. Markdown, text, and PDF include the report settings (dates,
134
+ accounts, category filters) and show amounts with a minus sign, since they may be printed
135
+ without color. Wide month-by-category reports print landscape in a smaller font.
136
+ - View → Font… sets the font family, style (regular/italic/bold), and size for the whole
137
+ app; the choice is remembered. View → Reset font returns to the system default.
138
+ - Save (Ctrl+S) writes atomically and keeps the previous file as `<file>.bak`.
139
+ - Handles a 10k+ row ledger quickly: about 0.6 s to start and 0.1 s to switch accounts.
140
+
141
+ ## Limitations (from the CountAbout format)
142
+
143
+ - Split transactions: existing `{SPLIT}` rows are preserved, but the export carries no
144
+ split detail, so new splits cannot be created and reports show splits as one lump.
145
+ - Balances are sums of rows; there is no separate opening-balance field. New accounts
146
+ get a "Beginning Balance" row.
147
+ - `Cleared` is X/blank only (no separate reconciled state).
148
+
149
+ ## License
150
+
151
+ MIT; see [LICENSE](https://github.com/devbisme/doppia/blob/master/LICENSE).
doppia-0.1.0/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # doppia
2
+
3
+ Personal accounting for bank and credit card accounts (PySide6 desktop GUI).
4
+ Data lives in a single CSV in CountAbout export format; files are written back
5
+ byte-for-byte compatible (unchanged rows are untouched, quoting matches CountAbout).
6
+
7
+ ## The name
8
+
9
+ doppia is named after *partita doppia*, Italian for double-entry bookkeeping. The method was
10
+ first described in print by Luca Pacioli (c. 1447–1517), an Italian mathematician and
11
+ Franciscan friar, in his *Summa de arithmetica, geometria, proportioni et proportionalita*
12
+ (Venice, 1494), which earned him the title "father of accounting".
13
+
14
+ ## Install
15
+
16
+ Install doppia from PyPI as a standalone app with
17
+ [pipx](https://pipx.pypa.io) or [uv](https://docs.astral.sh/uv/), which keep it isolated
18
+ without you having to manage a virtual environment:
19
+
20
+ pipx install doppia # or: uv tool install doppia
21
+
22
+ Python 3.10 or later is required. The clipboard import and **Guess with LLM** features also
23
+ need [Ollama](https://ollama.com) with a model installed, e.g. `ollama pull gemma4:12b-it-qat`;
24
+ everything else works without it.
25
+
26
+ ## Run
27
+
28
+ doppia path/to/countabout-export.csv # reopens the last file if omitted
29
+
30
+ ## Development
31
+
32
+ From a clone of the repository, [uv](https://docs.astral.sh/uv/) runs the app and tests
33
+ with the dependencies it needs:
34
+
35
+ uv run doppia path/to/countabout-export.csv
36
+ uv run --extra test pytest
37
+
38
+ ## Features
39
+
40
+ - Account list with today's balance and cleared balance. The bold **Total** row sums both
41
+ columns over the selected accounts (all accounts when none or "All accounts" is selected).
42
+ Future-dated rows shown grey.
43
+ - Money is shown as `$1,234.56`: red for negative, green for positive, no minus sign
44
+ (the minus is kept only in plain-text messages that can't be colored). Colors stay
45
+ visible on selected rows. `$` and commas are accepted when typing amounts.
46
+ - Register per account or all accounts: running balance, uncleared-only filter, and a
47
+ filter field that takes a case-insensitive regular expression (e.g. `^amazon`, `3000`,
48
+ `\$3,000`) matched in the description, category, memo, ref, date, or amount. Its X button
49
+ clears it; an invalid expression tints the field and is ignored. The account names over
50
+ the register are cut to 30 characters, including `...` (hover for the full list).
51
+ - Ctrl/Shift-click in the account list to select several accounts; the register shows their
52
+ combined transactions with a combined running balance. Reconcile and Rename account need
53
+ a single account selected.
54
+ - Click a column header to sort by it; click again to reverse. The sort column's header
55
+ is highlighted, and the selection is kept when re-sorting. The register opens sorted by
56
+ date, newest first. The Balance column is a date-order running balance, so it only
57
+ adds up when sorted by date.
58
+ - Click the Clr box to toggle cleared; cleared rows show a green ✔ (the CSV still stores `X`).
59
+ - Double-click a row to edit it; right-click for a menu to delete it.
60
+ - New/edit/delete (Ctrl+N / Ctrl+E / Ctrl+Del), Ctrl+K toggles cleared.
61
+ Payee memory: typing a known description fills its last category and amount.
62
+ - Search and replace (Transaction menu, Ctrl+H): find a regular expression in the
63
+ Description, Category, Memo, Ref, and/or Tags fields (optionally case-sensitive, optionally
64
+ only in the selected accounts). Check **Replace with** to preview each change (`\1` inserts a
65
+ matched group), untick any you don't want (**Select All/None** flips them all), then
66
+ **Replace checked**. Transfer/split
67
+ categories and results containing line breaks can't be replaced. Nothing is written to the
68
+ file until you save.
69
+ - Transfers use CountAbout's `{Account}` category; the mirror row in the other account
70
+ is created/updated/deleted automatically (matched by date and negated amount).
71
+ - Bank CSV import (Ctrl+I): column mapping saved as named profiles in
72
+ `~/.config/doppia/import_profiles.json`; payee/category matched from history;
73
+ duplicates (same amount within 3 days) unchecked by default; imported rows marked cleared.
74
+ - **Guess with LLM** asks the local model (the one chosen in clipboard import) which columns
75
+ hold the date, description, amount or debit/credit, memo, and check number, and the date
76
+ format. Only the header and first 8 rows are sent. Its answer is limited to the file's
77
+ real column names and checked against the sample rows; **Flip signs** is set from the
78
+ data (mostly positive amounts = card charges listed as positive), not by the model.
79
+ Takes a few seconds; save the result as a profile to skip it next time.
80
+ - Import from clipboard (File menu, Ctrl+Shift+V): copy transactions from a bank or card
81
+ website or statement, and a local LLM served by [Ollama](https://ollama.com) turns the text
82
+ into transactions for the chosen account (the active one by default). Nothing leaves your
83
+ machine. The text is shown for editing; press **Parse** (about 15–30 s on an RTX 3060).
84
+ - Model: any installed Ollama model, remembered between runs. Default `gemma4:12b-it-qat`,
85
+ which read sample bank and card text correctly; smaller models got dates and signs wrong.
86
+ Set `OLLAMA_HOST` if Ollama isn't at `localhost:11434`.
87
+ - Likely duplicates of existing transactions (same amount within 3 days) are removed from the
88
+ list; **Show likely duplicates** brings them back, unchecked. Payees and categories are
89
+ matched from history, as in CSV import.
90
+ - Review before importing: amounts the model returns that don't appear in the pasted text
91
+ are unchecked and flagged, and **Flip signs** fixes deposits/charges read backwards.
92
+ - Imported rows are marked cleared (optional) except ones shown as pending.
93
+ - Reconcile (Ctrl+R) against a statement ending balance: check off uncleared
94
+ transactions until the difference is $0.00.
95
+ - Reports (Ctrl+P): totals by category, category by month, income vs. expense by month.
96
+ Click a column header to sort.
97
+ - Transfers are excluded unless **Include transfers** is checked. Then each transfer is
98
+ reported under `Transfer: <other account>`. A transfer between two checked accounts appears
99
+ on both sides, so it adds to both income and expense while netting to zero.
100
+ - **Accounts All/None** checks or unchecks every account in the account list.
101
+ - **Categories** field: a case-insensitive regular expression matched anywhere in the
102
+ category name (e.g. `^(groceries|dining)$`). Press Enter or Run to apply; the X button
103
+ clears it and reruns. An invalid expression is reported and ignored.
104
+ - Ctrl/Shift-click category rows, then Run, to report only those categories. The
105
+ restriction persists across runs until **Show all categories** clears it (along with
106
+ the regex). Run uses whatever rows are highlighted when pressed.
107
+ - Total income, expense, and net rows reflect only the categories shown.
108
+ - **Save…** writes the report as shown (current sort order) to CSV (plain signed numbers for
109
+ spreadsheets), Markdown, plain text (aligned columns), or PDF. Markdown, text, and PDF include the report settings (dates,
110
+ accounts, category filters) and show amounts with a minus sign, since they may be printed
111
+ without color. Wide month-by-category reports print landscape in a smaller font.
112
+ - View → Font… sets the font family, style (regular/italic/bold), and size for the whole
113
+ app; the choice is remembered. View → Reset font returns to the system default.
114
+ - Save (Ctrl+S) writes atomically and keeps the previous file as `<file>.bak`.
115
+ - Handles a 10k+ row ledger quickly: about 0.6 s to start and 0.1 s to switch accounts.
116
+
117
+ ## Limitations (from the CountAbout format)
118
+
119
+ - Split transactions: existing `{SPLIT}` rows are preserved, but the export carries no
120
+ split detail, so new splits cannot be created and reports show splits as one lump.
121
+ - Balances are sums of rows; there is no separate opening-balance field. New accounts
122
+ get a "Beginning Balance" row.
123
+ - `Cleared` is X/blank only (no separate reconciled state).
124
+
125
+ ## License
126
+
127
+ MIT; see [LICENSE](https://github.com/devbisme/doppia/blob/master/LICENSE).
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"] # for SPDX license metadata
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "doppia"
7
+ version = "0.1.0"
8
+ description = "Personal accounting for bank and credit card accounts, stored in CountAbout CSV format"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Dave Vandenbout" }]
13
+ requires-python = ">=3.10"
14
+ keywords = ["accounting", "personal finance", "ledger", "countabout", "bookkeeping"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: X11 Applications :: Qt",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Office/Business :: Financial :: Accounting",
21
+ ]
22
+ dependencies = ["PySide6>=6.6"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/devbisme/doppia"
26
+ Source = "https://github.com/devbisme/doppia"
27
+ Issues = "https://github.com/devbisme/doppia/issues"
28
+ Releases = "https://github.com/devbisme/doppia/releases"
29
+
30
+ [project.optional-dependencies]
31
+ test = ["pytest"]
32
+
33
+ [project.gui-scripts]
34
+ doppia = "doppia.gui:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["test"]
41
+ pythonpath = ["src"]
doppia-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,79 @@
1
+ """Render a report (title, notes, headers, rows) as CSV, Markdown, plain text, or HTML (for PDF).
2
+
3
+ Row values are str, int, or Decimal; Decimals are monetary amounts.
4
+ """
5
+
6
+ import csv
7
+ import html
8
+ import io
9
+ from decimal import Decimal
10
+
11
+ NEG_HTML = "#be1e1e"
12
+ POS_HTML = "#149628"
13
+
14
+
15
+ def _money(value):
16
+ """Signed $ amount: exported files may be printed or viewed without color."""
17
+ return f"{'-' if value < 0 else ''}${abs(value):,.2f}"
18
+
19
+
20
+ def to_csv(headers, rows):
21
+ """Plain numbers (e.g. -709.30) so spreadsheets can total them."""
22
+ out = io.StringIO()
23
+ w = csv.writer(out, lineterminator="\n")
24
+ w.writerow(headers)
25
+ for row in rows:
26
+ w.writerow([f"{v:.2f}" if isinstance(v, Decimal) else v for v in row])
27
+ return out.getvalue()
28
+
29
+
30
+ def _md_cell(text):
31
+ return str(text).replace("|", "\\|")
32
+
33
+
34
+ def to_markdown(title, notes, headers, rows):
35
+ money_cols = {c for row in rows for c, v in enumerate(row) if isinstance(v, Decimal)}
36
+ lines = [f"# {title}", ""]
37
+ lines += [f"- {n}" for n in notes]
38
+ lines += ["", "| " + " | ".join(_md_cell(h) for h in headers) + " |"]
39
+ lines.append("|" + "|".join("---:" if c in money_cols else "---" for c in range(len(headers))) + "|")
40
+ for row in rows:
41
+ lines.append("| " + " | ".join(_md_cell(_money(v) if isinstance(v, Decimal) else v) for v in row) + " |")
42
+ return "\n".join(lines) + "\n"
43
+
44
+
45
+ def to_text(title, notes, headers, rows):
46
+ """Fixed-width columns: amounts and counts right-aligned, other text left-aligned."""
47
+ cells = [[_money(v) if isinstance(v, Decimal) else str(v) for v in row] for row in rows]
48
+ right = {c for row in rows for c, v in enumerate(row) if isinstance(v, (Decimal, int))}
49
+ widths = [max([len(h)] + [len(row[c]) for row in cells]) for c, h in enumerate(headers)]
50
+
51
+ def line(values):
52
+ return " ".join(v.rjust(w) if c in right else v.ljust(w) for c, (v, w) in enumerate(zip(values, widths))).rstrip()
53
+
54
+ lines = [title, "=" * len(title), ""] + notes + ["", line(headers), line(["-" * w for w in widths])]
55
+ lines += [line(row) for row in cells]
56
+ return "\n".join(lines) + "\n"
57
+
58
+
59
+ def to_html(title, notes, headers, rows, total_prefix=None, font_pt=9):
60
+ """Simple table document; QTextDocument renders it to PDF. Total rows are bold."""
61
+
62
+ def cell(v):
63
+ if isinstance(v, Decimal):
64
+ color = NEG_HTML if v < 0 else POS_HTML if v > 0 else None
65
+ style = f' style="color:{color}"' if color else ""
66
+ return f'<td align="right" nowrap{style}>{_money(v)}</td>'
67
+ align = ' align="right"' if isinstance(v, int) else ""
68
+ return f"<td nowrap{align}>{html.escape(str(v))}</td>"
69
+
70
+ parts = [f'<body style="font-size:{font_pt}pt">', f"<h2>{html.escape(title)}</h2>"]
71
+ parts += [f'<p style="margin:0">{html.escape(n)}</p>' for n in notes]
72
+ parts.append('<br><table border="1" cellspacing="0" cellpadding="3" style="border-collapse:collapse">')
73
+ parts.append("<tr>" + "".join(f"<th nowrap>{html.escape(h)}</th>" for h in headers) + "</tr>")
74
+ for row in rows:
75
+ bold = total_prefix and isinstance(row[0], str) and row[0].startswith(total_prefix)
76
+ tr = "".join(cell(v) for v in row)
77
+ parts.append(f'<tr style="font-weight:bold">{tr}</tr>' if bold else f"<tr>{tr}</tr>")
78
+ parts.append("</table></body>")
79
+ return "\n".join(parts)