ledgerkit 1.0.0.dev1__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.
- ledgerkit/__init__.py +51 -0
- ledgerkit/__main__.py +12 -0
- ledgerkit/_pandas_compat.py +21 -0
- ledgerkit/checks.py +617 -0
- ledgerkit/cli.py +448 -0
- ledgerkit/commodity_style.py +274 -0
- ledgerkit/editor_model.py +193 -0
- ledgerkit/loader.py +311 -0
- ledgerkit/models.py +459 -0
- ledgerkit/parser.py +1547 -0
- ledgerkit/reports.py +573 -0
- ledgerkit/writer.py +97 -0
- ledgerkit-1.0.0.dev1.dist-info/METADATA +203 -0
- ledgerkit-1.0.0.dev1.dist-info/RECORD +18 -0
- ledgerkit-1.0.0.dev1.dist-info/WHEEL +5 -0
- ledgerkit-1.0.0.dev1.dist-info/entry_points.txt +2 -0
- ledgerkit-1.0.0.dev1.dist-info/licenses/LICENSE +21 -0
- ledgerkit-1.0.0.dev1.dist-info/top_level.txt +1 -0
ledgerkit/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""ledgerkit: a Python implementation of the hledger plain-text accounting tool."""
|
|
2
|
+
|
|
3
|
+
from ledgerkit.loader import load_journal as load
|
|
4
|
+
from ledgerkit.models import (
|
|
5
|
+
BalanceAssertion,
|
|
6
|
+
BalanceRow,
|
|
7
|
+
Query,
|
|
8
|
+
RegisterRow,
|
|
9
|
+
ReportSection,
|
|
10
|
+
ReportSpec,
|
|
11
|
+
ReportSectionResult,
|
|
12
|
+
SourceSpan,
|
|
13
|
+
)
|
|
14
|
+
from ledgerkit.parser import parse_string_lenient, resolve_elision
|
|
15
|
+
from ledgerkit.reports import (
|
|
16
|
+
AccountsResult,
|
|
17
|
+
BalanceResult,
|
|
18
|
+
JournalStats,
|
|
19
|
+
RegisterResult,
|
|
20
|
+
balance_from_spec,
|
|
21
|
+
)
|
|
22
|
+
from ledgerkit.checks import CheckError, check_transaction_autobalanced
|
|
23
|
+
from ledgerkit.writer import journal_to_text, transaction_to_text
|
|
24
|
+
from ledgerkit.editor_model import EditorDocument
|
|
25
|
+
from ledgerkit.commodity_style import CommodityStyle
|
|
26
|
+
|
|
27
|
+
__version__ = "1.0.0.dev1"
|
|
28
|
+
__all__ = [
|
|
29
|
+
"load",
|
|
30
|
+
"AccountsResult",
|
|
31
|
+
"BalanceAssertion",
|
|
32
|
+
"BalanceResult",
|
|
33
|
+
"BalanceRow",
|
|
34
|
+
"CheckError",
|
|
35
|
+
"CommodityStyle",
|
|
36
|
+
"EditorDocument",
|
|
37
|
+
"JournalStats",
|
|
38
|
+
"RegisterResult",
|
|
39
|
+
"RegisterRow",
|
|
40
|
+
"ReportSection",
|
|
41
|
+
"ReportSpec",
|
|
42
|
+
"ReportSectionResult",
|
|
43
|
+
"SourceSpan",
|
|
44
|
+
"balance_from_spec",
|
|
45
|
+
"check_transaction_autobalanced",
|
|
46
|
+
"journal_to_text",
|
|
47
|
+
"parse_string_lenient",
|
|
48
|
+
"resolve_elision",
|
|
49
|
+
"transaction_to_text",
|
|
50
|
+
"__version__",
|
|
51
|
+
]
|
ledgerkit/__main__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Lazy import helper for optional pandas dependency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import pandas as pd # noqa: F401 — type hints only
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def require_pandas():
|
|
12
|
+
"""Import and return the pandas module, raising ImportError with a helpful
|
|
13
|
+
message if it is not installed."""
|
|
14
|
+
try:
|
|
15
|
+
import pandas as pd
|
|
16
|
+
return pd
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"pandas is required for DataFrame export. "
|
|
20
|
+
"Install it with: pip install ledgerkit[pandas]"
|
|
21
|
+
) from None
|
ledgerkit/checks.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""Validation checks for ledgerkit journals.
|
|
2
|
+
|
|
3
|
+
Each check function accepts a Journal and returns a (possibly empty) list of
|
|
4
|
+
CheckError instances. An empty list means the check passed. Checks do not
|
|
5
|
+
print anything; callers (e.g. cli.py) format and display the errors.
|
|
6
|
+
|
|
7
|
+
Check tiers mirror hledger's classification:
|
|
8
|
+
basic — run by default on every CLI command
|
|
9
|
+
strict — run when -s/--strict is given (in addition to basic)
|
|
10
|
+
other — run only when explicitly named in the `check` command
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
|
|
20
|
+
from ledgerkit.models import Journal, Posting, Transaction
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Public constants
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
BASIC_CHECK_NAMES: tuple[str, ...] = ("parseable", "autobalanced", "assertions")
|
|
28
|
+
STRICT_CHECK_NAMES: tuple[str, ...] = ("accounts", "commodities")
|
|
29
|
+
OTHER_CHECK_NAMES: tuple[str, ...] = ("payees", "ordereddates", "uniqueleafnames")
|
|
30
|
+
|
|
31
|
+
ALL_CHECK_NAMES: tuple[str, ...] = (
|
|
32
|
+
BASIC_CHECK_NAMES + STRICT_CHECK_NAMES + OTHER_CHECK_NAMES
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Error type
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CheckError:
|
|
42
|
+
"""A single validation failure produced by a check function.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
check_name: Identifier of the check that raised this error (e.g.
|
|
46
|
+
"autobalanced").
|
|
47
|
+
message: Human-readable description of the failure. May be
|
|
48
|
+
multi-line (e.g. strict checks include source context).
|
|
49
|
+
line_number: 1-based source line of the offending transaction or
|
|
50
|
+
posting, or None when not available (e.g. for errors
|
|
51
|
+
that span multiple lines).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
check_name: str
|
|
55
|
+
message: str
|
|
56
|
+
line_number: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Basic checks
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def check_parseable(journal: Journal) -> list[CheckError]:
|
|
64
|
+
"""Check that the journal was parseable — always passes at this point."""
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_autobalanced(journal: Journal) -> list[CheckError]:
|
|
69
|
+
"""Check that every transaction's postings net to zero per commodity.
|
|
70
|
+
|
|
71
|
+
One elided posting (amount=None) is allowed per transaction; its amount
|
|
72
|
+
is implicitly inferred as the negative sum of the remaining postings.
|
|
73
|
+
When an elided posting is present the transaction is always considered
|
|
74
|
+
balanced (the elision defines the missing amount). When no postings are
|
|
75
|
+
elided, every commodity's net must be exactly zero.
|
|
76
|
+
|
|
77
|
+
Multi-commodity transactions with an elided posting are flagged as an
|
|
78
|
+
error because the inferred amount is ambiguous.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
journal: The journal to check.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of CheckError, one per unbalanced transaction.
|
|
85
|
+
"""
|
|
86
|
+
errors: list[CheckError] = []
|
|
87
|
+
for txn in journal.transactions:
|
|
88
|
+
errs = _check_txn_balanced(txn)
|
|
89
|
+
errors.extend(errs)
|
|
90
|
+
return errors
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_transaction_autobalanced(txn: Transaction) -> list[CheckError]:
|
|
94
|
+
"""Run the autobalanced check on a single Transaction.
|
|
95
|
+
|
|
96
|
+
Returns [] if balanced or if the transaction has exactly one elided posting.
|
|
97
|
+
Returns a list containing one CheckError per unbalanced commodity otherwise.
|
|
98
|
+
Does not raise.
|
|
99
|
+
"""
|
|
100
|
+
return _check_txn_balanced(txn)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_txn_balanced(txn: Transaction) -> list[CheckError]:
|
|
104
|
+
"""Return CheckError list for a single transaction."""
|
|
105
|
+
elided = [p for p in txn.postings if p.amount is None]
|
|
106
|
+
label = f"transaction on {txn.date} ({txn.description!r})"
|
|
107
|
+
|
|
108
|
+
if len(elided) > 1:
|
|
109
|
+
return [CheckError(
|
|
110
|
+
check_name="autobalanced",
|
|
111
|
+
message=f"{label}: multiple elided postings (autobalanced)",
|
|
112
|
+
line_number=txn.source_line,
|
|
113
|
+
)]
|
|
114
|
+
|
|
115
|
+
if len(elided) == 1:
|
|
116
|
+
# Always balanced by definition: resolve_elision() will fill in the
|
|
117
|
+
# inferred amount(s). hledger allows one elided posting regardless of
|
|
118
|
+
# how many commodities are present.
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
# Zero elided postings — all commodity nets must be exactly zero.
|
|
122
|
+
commodity_sums: dict[str, Decimal] = {}
|
|
123
|
+
for p in txn.postings:
|
|
124
|
+
c = p.amount.commodity # type: ignore[union-attr]
|
|
125
|
+
commodity_sums[c] = commodity_sums.get(c, Decimal(0)) + p.amount.quantity # type: ignore[union-attr]
|
|
126
|
+
|
|
127
|
+
errors: list[CheckError] = []
|
|
128
|
+
for commodity, net in sorted(commodity_sums.items()):
|
|
129
|
+
if net != 0:
|
|
130
|
+
errors.append(CheckError(
|
|
131
|
+
check_name="autobalanced",
|
|
132
|
+
message=f"{label}: not balanced — net {net:+} {commodity}",
|
|
133
|
+
line_number=txn.source_line,
|
|
134
|
+
))
|
|
135
|
+
return errors
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Source-context formatting helpers (used by strict checks)
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def _fmt_txn_header(txn: Transaction) -> str:
|
|
143
|
+
"""Reconstruct a transaction header line from its model fields."""
|
|
144
|
+
parts = [str(txn.date)]
|
|
145
|
+
if txn.cleared:
|
|
146
|
+
parts.append("*")
|
|
147
|
+
elif txn.pending:
|
|
148
|
+
parts.append("!")
|
|
149
|
+
if txn.code:
|
|
150
|
+
parts.append(f"({txn.code})")
|
|
151
|
+
if txn.description:
|
|
152
|
+
parts.append(txn.description)
|
|
153
|
+
result = " ".join(parts)
|
|
154
|
+
if txn.comment:
|
|
155
|
+
result += f" ; {txn.comment}"
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _fmt_amount(amount: "Amount | None") -> str:
|
|
160
|
+
"""Format an Amount back to a compact display string."""
|
|
161
|
+
if amount is None:
|
|
162
|
+
return ""
|
|
163
|
+
qty = amount.quantity
|
|
164
|
+
sym = amount.commodity
|
|
165
|
+
if not sym:
|
|
166
|
+
return str(qty)
|
|
167
|
+
if sym[0].isalpha():
|
|
168
|
+
return f"{qty} {sym}"
|
|
169
|
+
if qty < 0:
|
|
170
|
+
return f"-{sym}{-qty}"
|
|
171
|
+
return f"{sym}{qty}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _fmt_posting(posting: "Posting") -> str:
|
|
175
|
+
"""Reconstruct a posting line with standard 4-space indent."""
|
|
176
|
+
if posting.amount is None:
|
|
177
|
+
return f" {posting.account}"
|
|
178
|
+
return f" {posting.account} {_fmt_amount(posting.amount)}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _build_strict_context(
|
|
182
|
+
journal: Journal,
|
|
183
|
+
txn: Transaction,
|
|
184
|
+
bad_posting: "Posting",
|
|
185
|
+
caret_offset: int,
|
|
186
|
+
caret_len: int,
|
|
187
|
+
) -> list[str]:
|
|
188
|
+
"""Build the source-context block used by strict check error messages.
|
|
189
|
+
|
|
190
|
+
Returns a list of lines:
|
|
191
|
+
{pad} | {txn_header}
|
|
192
|
+
{N} | {posting_with_issue}
|
|
193
|
+
{pad} | {carets}
|
|
194
|
+
{pad} | {other_posting}
|
|
195
|
+
...
|
|
196
|
+
"""
|
|
197
|
+
source_file = journal.source_file or "<unknown>"
|
|
198
|
+
p_line = bad_posting.source_line
|
|
199
|
+
line_str = str(p_line) if p_line is not None else "?"
|
|
200
|
+
num_w = max(len(line_str), 1)
|
|
201
|
+
pad = " " * num_w
|
|
202
|
+
|
|
203
|
+
lines = [f"Error: {source_file}:{line_str}:"]
|
|
204
|
+
lines.append(f"{pad} | {_fmt_txn_header(txn)}")
|
|
205
|
+
|
|
206
|
+
for p in txn.postings:
|
|
207
|
+
ptext = _fmt_posting(p)
|
|
208
|
+
if p is bad_posting:
|
|
209
|
+
lines.append(f"{line_str} | {ptext}")
|
|
210
|
+
carets = " " * caret_offset + "^" * caret_len
|
|
211
|
+
lines.append(f"{pad} | {carets}")
|
|
212
|
+
else:
|
|
213
|
+
lines.append(f"{pad} | {ptext}")
|
|
214
|
+
|
|
215
|
+
return lines
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Strict checks
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def check_accounts(journal: Journal) -> list[CheckError]:
|
|
223
|
+
"""Check that every posting account has been declared.
|
|
224
|
+
|
|
225
|
+
Stops at the first undeclared account found (by transaction then posting
|
|
226
|
+
order), matching hledger behaviour. Comparison is case-sensitive.
|
|
227
|
+
When declared_accounts is empty (no account directives were used),
|
|
228
|
+
all posting accounts are considered undeclared.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
journal: The journal to check.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
A list containing at most one CheckError with source context, or
|
|
235
|
+
an empty list when all accounts are declared.
|
|
236
|
+
"""
|
|
237
|
+
declared = set(journal.declared_accounts)
|
|
238
|
+
for txn in journal.transactions:
|
|
239
|
+
for posting in txn.postings:
|
|
240
|
+
if posting.account not in declared:
|
|
241
|
+
# Carets underline the account name in the posting line.
|
|
242
|
+
# _fmt_posting produces " {account} {amount}". The display
|
|
243
|
+
# format is "N | {ptext}", so account starts 4 chars into ptext.
|
|
244
|
+
caret_offset = 4 # 4 spaces indent in _fmt_posting
|
|
245
|
+
caret_len = len(posting.account)
|
|
246
|
+
ctx = _build_strict_context(
|
|
247
|
+
journal, txn, posting, caret_offset, caret_len
|
|
248
|
+
)
|
|
249
|
+
ctx += [
|
|
250
|
+
"",
|
|
251
|
+
"Strict account checking is enabled, and",
|
|
252
|
+
f'account "{posting.account}" has not been declared.',
|
|
253
|
+
"Consider adding an account directive. Examples:",
|
|
254
|
+
"",
|
|
255
|
+
f"account {posting.account}",
|
|
256
|
+
f"account {posting.account} ; type:A ; (L,E,R,X,C,V)",
|
|
257
|
+
]
|
|
258
|
+
return [CheckError(
|
|
259
|
+
check_name="accounts",
|
|
260
|
+
message="\n".join(ctx),
|
|
261
|
+
line_number=posting.source_line,
|
|
262
|
+
)]
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def check_commodities(journal: Journal) -> list[CheckError]:
|
|
267
|
+
"""Check that every commodity symbol in use has been declared.
|
|
268
|
+
|
|
269
|
+
Stops at the first undeclared commodity found, matching hledger behaviour.
|
|
270
|
+
Zero-amount postings (commodity symbol == "") are always exempt.
|
|
271
|
+
When declared_commodities is empty, all non-empty symbols are undeclared.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
journal: The journal to check.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
A list containing at most one CheckError with source context, or
|
|
278
|
+
an empty list when all commodities are declared.
|
|
279
|
+
"""
|
|
280
|
+
declared = set(journal.declared_commodities)
|
|
281
|
+
for txn in journal.transactions:
|
|
282
|
+
for posting in txn.postings:
|
|
283
|
+
if posting.amount is None:
|
|
284
|
+
continue
|
|
285
|
+
sym = posting.amount.commodity
|
|
286
|
+
if sym == "":
|
|
287
|
+
continue # zero-amount postings are exempt
|
|
288
|
+
if sym not in declared:
|
|
289
|
+
# Carets underline the amount (commodity + quantity) in the
|
|
290
|
+
# posting line. _fmt_posting gives " {account} {amount}",
|
|
291
|
+
# so amount starts at 4 + len(account) + 2 into the ptext.
|
|
292
|
+
amt_str = _fmt_amount(posting.amount)
|
|
293
|
+
caret_offset = 4 + len(posting.account) + 2 # indent + account + 2-space sep
|
|
294
|
+
caret_len = max(len(amt_str), 1)
|
|
295
|
+
ctx = _build_strict_context(
|
|
296
|
+
journal, txn, posting, caret_offset, caret_len
|
|
297
|
+
)
|
|
298
|
+
ctx += [
|
|
299
|
+
"",
|
|
300
|
+
"Strict commodity checking is enabled, and",
|
|
301
|
+
f'commodity "{sym}" has not been declared.',
|
|
302
|
+
"Consider adding a commodity directive. Examples:",
|
|
303
|
+
"",
|
|
304
|
+
f"commodity {sym}",
|
|
305
|
+
f"commodity {amt_str}",
|
|
306
|
+
]
|
|
307
|
+
return [CheckError(
|
|
308
|
+
check_name="commodities",
|
|
309
|
+
message="\n".join(ctx),
|
|
310
|
+
line_number=posting.source_line,
|
|
311
|
+
)]
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Other checks
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def check_payees(journal: Journal) -> list[CheckError]:
|
|
320
|
+
"""Check that every transaction description is a declared payee.
|
|
321
|
+
|
|
322
|
+
When declared_payees is empty (no payee directives), all descriptions
|
|
323
|
+
are considered undeclared.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
journal: The journal to check.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
List of CheckError, one per undeclared payee name.
|
|
330
|
+
"""
|
|
331
|
+
declared = set(journal.declared_payees)
|
|
332
|
+
|
|
333
|
+
errors: list[CheckError] = []
|
|
334
|
+
seen_undeclared: set[str] = set()
|
|
335
|
+
for txn in journal.transactions:
|
|
336
|
+
name = txn.description
|
|
337
|
+
if name not in declared and name not in seen_undeclared:
|
|
338
|
+
seen_undeclared.add(name)
|
|
339
|
+
errors.append(CheckError(
|
|
340
|
+
check_name="payees",
|
|
341
|
+
message=f"payee not declared: {name!r} (payees check)",
|
|
342
|
+
line_number=txn.source_line,
|
|
343
|
+
))
|
|
344
|
+
return errors
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def check_ordereddates(journal: Journal) -> list[CheckError]:
|
|
348
|
+
"""Check that transactions are in non-decreasing date order.
|
|
349
|
+
|
|
350
|
+
hledger evaluates this per-file, but since ledgerkit merges included
|
|
351
|
+
files before parsing, this check operates on the full merged transaction
|
|
352
|
+
list in journal order.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
journal: The journal to check.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
List of CheckError, one per out-of-order transaction.
|
|
359
|
+
"""
|
|
360
|
+
errors: list[CheckError] = []
|
|
361
|
+
prev_date: datetime.date | None = None
|
|
362
|
+
for txn in journal.transactions:
|
|
363
|
+
if prev_date is not None and txn.date < prev_date:
|
|
364
|
+
errors.append(CheckError(
|
|
365
|
+
check_name="ordereddates",
|
|
366
|
+
message=(
|
|
367
|
+
f"transaction on {txn.date} ({txn.description!r}) is out of order "
|
|
368
|
+
f"— appears after {prev_date} (ordereddates check)"
|
|
369
|
+
),
|
|
370
|
+
line_number=txn.source_line,
|
|
371
|
+
))
|
|
372
|
+
else:
|
|
373
|
+
prev_date = txn.date
|
|
374
|
+
return errors
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def check_uniqueleafnames(journal: Journal) -> list[CheckError]:
|
|
378
|
+
"""Check that no two accounts share the same last colon-segment.
|
|
379
|
+
|
|
380
|
+
hledger uses the last colon-segment (leaf) for short account name
|
|
381
|
+
matching, so duplicate leaves make disambiguation impossible.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
journal: The journal to check.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
List of CheckError, one per duplicate leaf name.
|
|
388
|
+
"""
|
|
389
|
+
all_accounts = {p.account for t in journal.transactions for p in t.postings}
|
|
390
|
+
|
|
391
|
+
leaf_to_accounts: dict[str, list[str]] = {}
|
|
392
|
+
for acct in all_accounts:
|
|
393
|
+
leaf = acct.rsplit(":", 1)[-1]
|
|
394
|
+
leaf_to_accounts.setdefault(leaf, []).append(acct)
|
|
395
|
+
|
|
396
|
+
errors: list[CheckError] = []
|
|
397
|
+
for leaf, accounts in sorted(leaf_to_accounts.items()):
|
|
398
|
+
if len(accounts) > 1:
|
|
399
|
+
dupes = ", ".join(sorted(accounts))
|
|
400
|
+
errors.append(CheckError(
|
|
401
|
+
check_name="uniqueleafnames",
|
|
402
|
+
message=(
|
|
403
|
+
f"duplicate leaf account name {leaf!r}: {dupes} "
|
|
404
|
+
f"(uniqueleafnames check)"
|
|
405
|
+
),
|
|
406
|
+
))
|
|
407
|
+
return errors
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
# Balance assertion check
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
def check_assertions(journal: Journal) -> list[CheckError]:
|
|
415
|
+
"""Check that every balance assertion in the journal holds.
|
|
416
|
+
|
|
417
|
+
Processes postings in date order (then parse order within the same date),
|
|
418
|
+
maintaining a running balance per account per commodity. When a posting
|
|
419
|
+
carries a balance assertion, the running balance at that point is compared
|
|
420
|
+
to the expected value.
|
|
421
|
+
|
|
422
|
+
Variants:
|
|
423
|
+
= single-commodity, subaccount-exclusive
|
|
424
|
+
== sole-commodity, subaccount-exclusive (no other commodity may be non-zero)
|
|
425
|
+
=* single-commodity, subaccount-inclusive
|
|
426
|
+
==* sole-commodity, subaccount-inclusive
|
|
427
|
+
|
|
428
|
+
Costs and posting status are ignored (per hledger spec).
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
journal: The journal to check.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
List of CheckError, one per failed assertion.
|
|
435
|
+
"""
|
|
436
|
+
errors: list[CheckError] = []
|
|
437
|
+
|
|
438
|
+
# Sort (txn, posting) pairs by date then parse order within the same date.
|
|
439
|
+
# source_line=None is treated as 0 so unknown lines sort before known ones.
|
|
440
|
+
pairs: list[tuple[Transaction, object]] = []
|
|
441
|
+
for txn in sorted(
|
|
442
|
+
journal.transactions,
|
|
443
|
+
key=lambda t: (t.date, t.source_line or 0),
|
|
444
|
+
):
|
|
445
|
+
for posting in txn.postings:
|
|
446
|
+
pairs.append((txn, posting))
|
|
447
|
+
|
|
448
|
+
# running_balances[account][commodity] = running net quantity
|
|
449
|
+
running_balances: dict[str, dict[str, Decimal]] = defaultdict(
|
|
450
|
+
lambda: defaultdict(Decimal)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
for txn, posting in pairs:
|
|
454
|
+
assert isinstance(posting, Posting)
|
|
455
|
+
|
|
456
|
+
if posting.amount is not None:
|
|
457
|
+
running_balances[posting.account][posting.amount.commodity] += (
|
|
458
|
+
posting.amount.quantity
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
ba = posting.balance_assertion
|
|
462
|
+
if ba is None:
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
commodity = ba.amount.commodity
|
|
466
|
+
expected = ba.amount.quantity
|
|
467
|
+
|
|
468
|
+
# Compute actual balance for this commodity, inclusive or exclusive.
|
|
469
|
+
if ba.inclusive:
|
|
470
|
+
prefix = posting.account + ":"
|
|
471
|
+
actual = sum(
|
|
472
|
+
acct_bal.get(commodity, Decimal(0))
|
|
473
|
+
for acct, acct_bal in running_balances.items()
|
|
474
|
+
if acct == posting.account or acct.startswith(prefix)
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
actual = running_balances[posting.account].get(commodity, Decimal(0))
|
|
478
|
+
|
|
479
|
+
if actual != expected:
|
|
480
|
+
errors.append(CheckError(
|
|
481
|
+
check_name="assertions",
|
|
482
|
+
message=(
|
|
483
|
+
f"balance assertion failed for {posting.account!r} on {txn.date}: "
|
|
484
|
+
f"expected {commodity}{expected}, got {commodity}{actual}"
|
|
485
|
+
+ (f" (line {posting.source_line})" if posting.source_line else "")
|
|
486
|
+
),
|
|
487
|
+
line_number=posting.source_line,
|
|
488
|
+
))
|
|
489
|
+
|
|
490
|
+
if ba.sole_commodity:
|
|
491
|
+
# All other commodities in the account (or subtree) must be zero.
|
|
492
|
+
if ba.inclusive:
|
|
493
|
+
prefix = posting.account + ":"
|
|
494
|
+
combined: dict[str, Decimal] = defaultdict(Decimal)
|
|
495
|
+
for acct, acct_bal in running_balances.items():
|
|
496
|
+
if acct == posting.account or acct.startswith(prefix):
|
|
497
|
+
for comm, qty in acct_bal.items():
|
|
498
|
+
combined[comm] += qty
|
|
499
|
+
other_balances = dict(combined)
|
|
500
|
+
else:
|
|
501
|
+
other_balances = dict(running_balances[posting.account])
|
|
502
|
+
|
|
503
|
+
for other_comm, other_qty in sorted(other_balances.items()):
|
|
504
|
+
if other_comm != commodity and other_qty != Decimal(0):
|
|
505
|
+
errors.append(CheckError(
|
|
506
|
+
check_name="assertions",
|
|
507
|
+
message=(
|
|
508
|
+
f"sole-commodity assertion failed for "
|
|
509
|
+
f"{posting.account!r} on {txn.date}: "
|
|
510
|
+
f"unexpected non-zero {other_comm} balance {other_qty}"
|
|
511
|
+
+ (f" (line {posting.source_line})" if posting.source_line else "")
|
|
512
|
+
),
|
|
513
|
+
line_number=posting.source_line,
|
|
514
|
+
))
|
|
515
|
+
|
|
516
|
+
return errors
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ---------------------------------------------------------------------------
|
|
520
|
+
# Check registry and runners
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
_CHECK_FN = {
|
|
524
|
+
"parseable": check_parseable,
|
|
525
|
+
"autobalanced": check_autobalanced,
|
|
526
|
+
"assertions": check_assertions,
|
|
527
|
+
"accounts": check_accounts,
|
|
528
|
+
"commodities": check_commodities,
|
|
529
|
+
"payees": check_payees,
|
|
530
|
+
"ordereddates": check_ordereddates,
|
|
531
|
+
"uniqueleafnames": check_uniqueleafnames,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def run_basic_checks(
|
|
536
|
+
journal: Journal,
|
|
537
|
+
*,
|
|
538
|
+
skip: frozenset[str] | None = None,
|
|
539
|
+
) -> list[CheckError]:
|
|
540
|
+
"""Run the basic checks (parseable, autobalanced, assertions) and return all errors.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
journal: The journal to check.
|
|
544
|
+
skip: Optional set of check names to omit even if they are basic checks
|
|
545
|
+
(e.g. ``frozenset({"assertions"})`` when ``-I`` is passed).
|
|
546
|
+
"""
|
|
547
|
+
_skip = skip or frozenset()
|
|
548
|
+
errors: list[CheckError] = []
|
|
549
|
+
for name in BASIC_CHECK_NAMES:
|
|
550
|
+
if name not in _skip:
|
|
551
|
+
errors.extend(_CHECK_FN[name](journal))
|
|
552
|
+
return errors
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def run_strict_checks(
|
|
556
|
+
journal: Journal,
|
|
557
|
+
*,
|
|
558
|
+
skip: frozenset[str] | None = None,
|
|
559
|
+
) -> list[CheckError]:
|
|
560
|
+
"""Run basic + strict checks and return all errors."""
|
|
561
|
+
errors = run_basic_checks(journal, skip=skip)
|
|
562
|
+
for name in STRICT_CHECK_NAMES:
|
|
563
|
+
errors.extend(_CHECK_FN[name](journal))
|
|
564
|
+
return errors
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def run_checks(
|
|
568
|
+
journal: Journal,
|
|
569
|
+
names: list[str] | None = None,
|
|
570
|
+
*,
|
|
571
|
+
strict: bool = False,
|
|
572
|
+
skip: frozenset[str] | None = None,
|
|
573
|
+
) -> list[CheckError]:
|
|
574
|
+
"""Run checks and return all errors.
|
|
575
|
+
|
|
576
|
+
Always runs basic checks. When strict=True, also runs strict checks.
|
|
577
|
+
Any names in `names` that are not already in the basic/strict tier are
|
|
578
|
+
run in addition.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
journal: The journal to check.
|
|
582
|
+
names: Optional list of additional check names to run (from
|
|
583
|
+
OTHER_CHECK_NAMES, or any valid check name).
|
|
584
|
+
strict: If True, run strict checks in addition to basic.
|
|
585
|
+
skip: Optional set of check names to suppress entirely (e.g.
|
|
586
|
+
``frozenset({"assertions"})`` for the ``-I`` flag).
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
Combined list of CheckError from all requested checks.
|
|
590
|
+
|
|
591
|
+
Raises:
|
|
592
|
+
ValueError: if a name in `names` is not a recognised check.
|
|
593
|
+
"""
|
|
594
|
+
_skip = skip or frozenset()
|
|
595
|
+
names = names or []
|
|
596
|
+
for name in names:
|
|
597
|
+
if name not in _CHECK_FN:
|
|
598
|
+
raise ValueError(
|
|
599
|
+
f"unknown check {name!r}; available: {', '.join(sorted(_CHECK_FN))}"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
errors = run_basic_checks(journal, skip=_skip)
|
|
603
|
+
|
|
604
|
+
if strict:
|
|
605
|
+
for name in STRICT_CHECK_NAMES:
|
|
606
|
+
errors.extend(_CHECK_FN[name](journal))
|
|
607
|
+
|
|
608
|
+
already_run = set(BASIC_CHECK_NAMES)
|
|
609
|
+
if strict:
|
|
610
|
+
already_run |= set(STRICT_CHECK_NAMES)
|
|
611
|
+
|
|
612
|
+
for name in names:
|
|
613
|
+
if name not in already_run and name not in _skip:
|
|
614
|
+
errors.extend(_CHECK_FN[name](journal))
|
|
615
|
+
already_run.add(name)
|
|
616
|
+
|
|
617
|
+
return errors
|