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/reports.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Report generators for ledgerkit.
|
|
2
|
+
|
|
3
|
+
Each function accepts a Journal and returns structured data — not formatted
|
|
4
|
+
strings. Formatting is handled by cli.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Iterator
|
|
15
|
+
|
|
16
|
+
from ledgerkit.models import (
|
|
17
|
+
BalanceRow,
|
|
18
|
+
Journal,
|
|
19
|
+
Posting,
|
|
20
|
+
Query,
|
|
21
|
+
RegisterRow,
|
|
22
|
+
ReportSection,
|
|
23
|
+
ReportSpec,
|
|
24
|
+
ReportSectionResult,
|
|
25
|
+
Transaction,
|
|
26
|
+
)
|
|
27
|
+
from ledgerkit.parser import resolve_elision
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Transparent result wrappers with to_dataframe() support
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
class BalanceResult(Mapping):
|
|
35
|
+
"""Return type of Journal.balance() (flat mode). Behaves like dict[str, dict[str, Decimal]]."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, data: dict, commodity_styles: dict) -> None:
|
|
38
|
+
self._data = data
|
|
39
|
+
self._commodity_styles = commodity_styles
|
|
40
|
+
|
|
41
|
+
def __getitem__(self, key: str) -> dict:
|
|
42
|
+
return self._data[key]
|
|
43
|
+
|
|
44
|
+
def __iter__(self) -> Iterator:
|
|
45
|
+
return iter(self._data)
|
|
46
|
+
|
|
47
|
+
def __len__(self) -> int:
|
|
48
|
+
return len(self._data)
|
|
49
|
+
|
|
50
|
+
def __eq__(self, other: object) -> bool:
|
|
51
|
+
if isinstance(other, BalanceResult):
|
|
52
|
+
return self._data == other._data
|
|
53
|
+
if isinstance(other, dict):
|
|
54
|
+
return self._data == other
|
|
55
|
+
return NotImplemented
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
return repr(self._data)
|
|
59
|
+
|
|
60
|
+
def to_dataframe(self):
|
|
61
|
+
"""Export balance to a DataFrame with account, amount, commodity, amount_formatted.
|
|
62
|
+
|
|
63
|
+
One row per (account, commodity) pair (non-zero amounts only).
|
|
64
|
+
Requires pandas: pip install ledgerkit[pandas]
|
|
65
|
+
"""
|
|
66
|
+
from ledgerkit._pandas_compat import require_pandas
|
|
67
|
+
pd = require_pandas()
|
|
68
|
+
rows = []
|
|
69
|
+
for account in sorted(self._data):
|
|
70
|
+
for commodity, qty in sorted(self._data[account].items()):
|
|
71
|
+
if qty == 0:
|
|
72
|
+
continue
|
|
73
|
+
style = self._commodity_styles.get(commodity)
|
|
74
|
+
from ledgerkit.commodity_style import CommodityStyle
|
|
75
|
+
formatted = style.format(qty) if style else str(qty)
|
|
76
|
+
rows.append({
|
|
77
|
+
"account": account,
|
|
78
|
+
"amount": qty,
|
|
79
|
+
"commodity": commodity,
|
|
80
|
+
"amount_formatted": formatted,
|
|
81
|
+
})
|
|
82
|
+
df = pd.DataFrame(rows, columns=["account", "amount", "commodity", "amount_formatted"])
|
|
83
|
+
if not df.empty:
|
|
84
|
+
df["amount"] = df["amount"].astype(object)
|
|
85
|
+
return df
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RegisterResult(Sequence):
|
|
89
|
+
"""Return type of Journal.register(). Behaves like list[RegisterRow]."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, data: list, commodity_styles: dict) -> None:
|
|
92
|
+
self._data = data
|
|
93
|
+
self._commodity_styles = commodity_styles
|
|
94
|
+
|
|
95
|
+
def __getitem__(self, index):
|
|
96
|
+
return self._data[index]
|
|
97
|
+
|
|
98
|
+
def __len__(self) -> int:
|
|
99
|
+
return len(self._data)
|
|
100
|
+
|
|
101
|
+
def __eq__(self, other: object) -> bool:
|
|
102
|
+
if isinstance(other, RegisterResult):
|
|
103
|
+
return self._data == other._data
|
|
104
|
+
if isinstance(other, list):
|
|
105
|
+
return self._data == other
|
|
106
|
+
return NotImplemented
|
|
107
|
+
|
|
108
|
+
def __repr__(self) -> str:
|
|
109
|
+
return repr(self._data)
|
|
110
|
+
|
|
111
|
+
def to_dataframe(self):
|
|
112
|
+
"""Export register rows to a DataFrame.
|
|
113
|
+
|
|
114
|
+
Columns: date, description, cleared, pending, account,
|
|
115
|
+
amount (Decimal), commodity, amount_formatted.
|
|
116
|
+
Requires pandas: pip install ledgerkit[pandas]
|
|
117
|
+
"""
|
|
118
|
+
from ledgerkit._pandas_compat import require_pandas
|
|
119
|
+
pd = require_pandas()
|
|
120
|
+
rows = []
|
|
121
|
+
for row in self._data:
|
|
122
|
+
commodity = row.amount.commodity
|
|
123
|
+
style = self._commodity_styles.get(commodity)
|
|
124
|
+
formatted = style.format(row.amount.quantity) if style else str(row.amount.quantity)
|
|
125
|
+
rows.append({
|
|
126
|
+
"date": row.date,
|
|
127
|
+
"description": row.description,
|
|
128
|
+
"cleared": False,
|
|
129
|
+
"pending": False,
|
|
130
|
+
"account": row.account,
|
|
131
|
+
"amount": row.amount.quantity,
|
|
132
|
+
"commodity": commodity,
|
|
133
|
+
"amount_formatted": formatted,
|
|
134
|
+
})
|
|
135
|
+
df = pd.DataFrame(rows, columns=[
|
|
136
|
+
"date", "description", "cleared", "pending", "account",
|
|
137
|
+
"amount", "commodity", "amount_formatted",
|
|
138
|
+
])
|
|
139
|
+
if not df.empty:
|
|
140
|
+
df["amount"] = df["amount"].astype(object)
|
|
141
|
+
return df
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class AccountsResult(Sequence):
|
|
145
|
+
"""Return type of Journal.accounts(). Behaves like list[str]."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, data: list) -> None:
|
|
148
|
+
self._data = data
|
|
149
|
+
|
|
150
|
+
def __getitem__(self, index):
|
|
151
|
+
return self._data[index]
|
|
152
|
+
|
|
153
|
+
def __len__(self) -> int:
|
|
154
|
+
return len(self._data)
|
|
155
|
+
|
|
156
|
+
def __eq__(self, other: object) -> bool:
|
|
157
|
+
if isinstance(other, AccountsResult):
|
|
158
|
+
return self._data == other._data
|
|
159
|
+
if isinstance(other, list):
|
|
160
|
+
return self._data == other
|
|
161
|
+
return NotImplemented
|
|
162
|
+
|
|
163
|
+
def __repr__(self) -> str:
|
|
164
|
+
return repr(self._data)
|
|
165
|
+
|
|
166
|
+
def to_dataframe(self):
|
|
167
|
+
"""Export account names to a single-column DataFrame.
|
|
168
|
+
|
|
169
|
+
Requires pandas: pip install ledgerkit[pandas]
|
|
170
|
+
"""
|
|
171
|
+
from ledgerkit._pandas_compat import require_pandas
|
|
172
|
+
pd = require_pandas()
|
|
173
|
+
return pd.DataFrame({"account": self._data})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class JournalStats:
|
|
178
|
+
"""Summary statistics for a journal.
|
|
179
|
+
|
|
180
|
+
Contains only deterministic journal data. Runtime stats (elapsed time,
|
|
181
|
+
txns/s) are measured and formatted by the CLI layer, not stored here.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
source_file: str | None
|
|
185
|
+
included_files: int
|
|
186
|
+
transaction_count: int
|
|
187
|
+
date_range: tuple[datetime.date, datetime.date] | None # (first, last)
|
|
188
|
+
last_txn_date: datetime.date | None
|
|
189
|
+
last_txn_days_ago: int | None # (today - last_txn_date).days
|
|
190
|
+
txns_span_days: int | None # (last - first).days + 1
|
|
191
|
+
txns_per_day: float # transaction_count / txns_span_days, else 0.0
|
|
192
|
+
txns_last_30_days: int
|
|
193
|
+
txns_per_day_last_30: float
|
|
194
|
+
txns_last_7_days: int
|
|
195
|
+
txns_per_day_last_7: float
|
|
196
|
+
payee_count: int # unique descriptions
|
|
197
|
+
account_count: int
|
|
198
|
+
account_depth: int # max colon-segment depth across all accounts
|
|
199
|
+
commodity_count: int
|
|
200
|
+
commodities: list[str] # sorted commodity symbols (shown in verbose mode)
|
|
201
|
+
price_count: int # len(journal.prices)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Private helpers
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
# Detects whether a user-supplied pattern contains any regex metacharacter,
|
|
209
|
+
# used to decide between plain substring matching and re.search().
|
|
210
|
+
#
|
|
211
|
+
# Purpose: distinguish plain strings like "expenses:food" from regex patterns
|
|
212
|
+
# like "^expenses" or "food.*". Any character that has special meaning
|
|
213
|
+
# in a Python regex triggers regex mode.
|
|
214
|
+
#
|
|
215
|
+
# Group breakdown: no capture groups — result used only as a boolean via .search().
|
|
216
|
+
#
|
|
217
|
+
# Edge cases:
|
|
218
|
+
# - A lone '.' is treated as a regex wildcard (matches any character), matching
|
|
219
|
+
# hledger's behaviour where '.' in an account filter is a metacharacter.
|
|
220
|
+
# - Backslash sequences like '\\(' are detected because '\\' is in the set,
|
|
221
|
+
# so escaped literals always go through regex mode.
|
|
222
|
+
# - An empty pattern never contains a metacharacter → plain substring mode.
|
|
223
|
+
_REGEX_META = re.compile(r'[\\^$.()\[\]{}*+?|]')
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _matches_pattern(pattern: str, value: str) -> bool:
|
|
227
|
+
"""Return True if pattern matches value using hledger substring/regex rules.
|
|
228
|
+
|
|
229
|
+
If pattern contains any regex metacharacter it is compiled as a regex and
|
|
230
|
+
matched via re.search (partial match, case-insensitive). Otherwise it is
|
|
231
|
+
treated as a plain case-insensitive substring match.
|
|
232
|
+
"""
|
|
233
|
+
if _REGEX_META.search(pattern):
|
|
234
|
+
return bool(re.search(pattern, value, re.IGNORECASE))
|
|
235
|
+
return pattern.lower() in value.lower()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _posting_matches(
|
|
239
|
+
posting: Posting,
|
|
240
|
+
txn: Transaction,
|
|
241
|
+
query: Query | None,
|
|
242
|
+
) -> bool:
|
|
243
|
+
"""Return True if the posting should be included given the query.
|
|
244
|
+
|
|
245
|
+
Transaction-level filters (date, payee) are checked against txn.
|
|
246
|
+
Posting-level filters (account, not_account, depth) are checked against posting.
|
|
247
|
+
|
|
248
|
+
A None query or a Query() with all None fields always returns True.
|
|
249
|
+
"""
|
|
250
|
+
if query is None:
|
|
251
|
+
return True
|
|
252
|
+
if query.date_from is not None and txn.date < query.date_from:
|
|
253
|
+
return False
|
|
254
|
+
if query.date_to is not None and txn.date > query.date_to:
|
|
255
|
+
return False
|
|
256
|
+
if query.payee is not None and not _matches_pattern(query.payee, txn.description):
|
|
257
|
+
return False
|
|
258
|
+
if query.account is not None and not _matches_pattern(query.account, posting.account):
|
|
259
|
+
return False
|
|
260
|
+
if query.not_account is not None and _matches_pattern(query.not_account, posting.account):
|
|
261
|
+
return False
|
|
262
|
+
if query.depth is not None and len(posting.account.split(":")) > query.depth:
|
|
263
|
+
return False
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _aggregate_posting_amounts(
|
|
268
|
+
pairs: list[tuple[str, Decimal]],
|
|
269
|
+
) -> dict[str, Decimal]:
|
|
270
|
+
"""Sum (account_name, quantity) pairs into an account → balance dict."""
|
|
271
|
+
result: dict[str, Decimal] = {}
|
|
272
|
+
for account, qty in pairs:
|
|
273
|
+
result[account] = result.get(account, Decimal(0)) + qty
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _build_balance_tree(
|
|
278
|
+
totals: dict[str, dict[str, Decimal]],
|
|
279
|
+
) -> list[BalanceRow]:
|
|
280
|
+
"""Convert a flat account→commodity→net dict into a sorted tree row list.
|
|
281
|
+
|
|
282
|
+
Collects all implicit parent accounts (all colon-prefixes of every account
|
|
283
|
+
name in `totals`), computes aggregate amounts (own postings + all
|
|
284
|
+
descendants) for each, and returns a list of BalanceRow sorted
|
|
285
|
+
alphabetically by account name.
|
|
286
|
+
|
|
287
|
+
is_subtotal is True for accounts not present in `totals` directly (pure
|
|
288
|
+
intermediate parents with no direct postings of their own).
|
|
289
|
+
"""
|
|
290
|
+
all_accounts: set[str] = set(totals.keys())
|
|
291
|
+
for account in list(totals.keys()):
|
|
292
|
+
parts = account.split(":")
|
|
293
|
+
for i in range(1, len(parts)):
|
|
294
|
+
all_accounts.add(":".join(parts[:i]))
|
|
295
|
+
|
|
296
|
+
rows: list[BalanceRow] = []
|
|
297
|
+
for account in sorted(all_accounts):
|
|
298
|
+
prefix = account + ":"
|
|
299
|
+
agg: dict[str, Decimal] = {}
|
|
300
|
+
for src, amounts in totals.items():
|
|
301
|
+
if src == account or src.startswith(prefix):
|
|
302
|
+
for commodity, qty in amounts.items():
|
|
303
|
+
agg[commodity] = agg.get(commodity, Decimal(0)) + qty
|
|
304
|
+
rows.append(BalanceRow(
|
|
305
|
+
account=account,
|
|
306
|
+
depth=account.count(":"),
|
|
307
|
+
amounts=agg,
|
|
308
|
+
is_subtotal=(account not in totals),
|
|
309
|
+
))
|
|
310
|
+
return rows
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
# Report functions
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
def accounts(journal: Journal, query: Query | None = None) -> list[str]:
|
|
318
|
+
"""Return a sorted list of all unique account names in the journal.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
journal: The parsed journal.
|
|
322
|
+
query: Optional filter. When None or Query(), all accounts are returned.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Sorted list of account name strings that appear in at least one
|
|
326
|
+
matching posting.
|
|
327
|
+
"""
|
|
328
|
+
seen: set[str] = set()
|
|
329
|
+
for txn in journal.transactions:
|
|
330
|
+
for posting in txn.postings:
|
|
331
|
+
if _posting_matches(posting, txn, query):
|
|
332
|
+
seen.add(posting.account)
|
|
333
|
+
return AccountsResult(sorted(seen))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def balance(
|
|
337
|
+
journal: Journal,
|
|
338
|
+
query: Query | None = None,
|
|
339
|
+
tree: bool = False,
|
|
340
|
+
) -> dict[str, dict[str, Decimal]] | list[BalanceRow]:
|
|
341
|
+
"""Return per-commodity net balances for each account.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
journal: The parsed journal.
|
|
345
|
+
query: Optional filter. When None or Query(), all postings are included.
|
|
346
|
+
tree: When True, returns list[BalanceRow] with implicit parent accounts
|
|
347
|
+
and aggregate subtotals. When False (default), returns a flat
|
|
348
|
+
dict[str, dict[str, Decimal]] mapping account name to a
|
|
349
|
+
commodity→net dict.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
When tree=False: dict mapping account name to {commodity: net_balance}.
|
|
353
|
+
When tree=True: list[BalanceRow] sorted alphabetically, including
|
|
354
|
+
implicit parent accounts with is_subtotal=True.
|
|
355
|
+
|
|
356
|
+
Note on depth: depth in the query causes account names to be *truncated*
|
|
357
|
+
(rolled up) rather than excluded — matching hledger's --depth behaviour.
|
|
358
|
+
expenses:food:groceries at depth=2 contributes to expenses:food.
|
|
359
|
+
"""
|
|
360
|
+
# Strip depth from the query before _posting_matches so deep postings are
|
|
361
|
+
# not excluded, then apply truncation to the account name manually.
|
|
362
|
+
matching_query = (
|
|
363
|
+
replace(query, depth=None)
|
|
364
|
+
if query is not None and query.depth is not None
|
|
365
|
+
else query
|
|
366
|
+
)
|
|
367
|
+
totals: dict[str, dict[str, Decimal]] = {}
|
|
368
|
+
for txn in journal.transactions:
|
|
369
|
+
for posting in resolve_elision(txn):
|
|
370
|
+
if not _posting_matches(posting, txn, matching_query):
|
|
371
|
+
continue
|
|
372
|
+
if posting.amount is None:
|
|
373
|
+
continue
|
|
374
|
+
account = posting.account
|
|
375
|
+
if query is not None and query.depth is not None:
|
|
376
|
+
account = ":".join(account.split(":")[:query.depth])
|
|
377
|
+
commodity = posting.amount.commodity
|
|
378
|
+
if account not in totals:
|
|
379
|
+
totals[account] = {}
|
|
380
|
+
totals[account][commodity] = (
|
|
381
|
+
totals[account].get(commodity, Decimal(0)) + posting.amount.quantity
|
|
382
|
+
)
|
|
383
|
+
if tree:
|
|
384
|
+
return _build_balance_tree(totals)
|
|
385
|
+
return BalanceResult(totals, journal.commodity_styles)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def register(
|
|
389
|
+
journal: Journal,
|
|
390
|
+
query: Query | None = None,
|
|
391
|
+
) -> list[RegisterRow]:
|
|
392
|
+
"""Return a chronological list of register rows.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
journal: The parsed journal.
|
|
396
|
+
query: Optional filter. When None or Query(), all postings are included.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
List of RegisterRow objects in journal order. running_balance is the
|
|
400
|
+
cumulative sum of amount.quantity across all rows in output order.
|
|
401
|
+
"""
|
|
402
|
+
rows: list[RegisterRow] = []
|
|
403
|
+
running: Decimal = Decimal(0)
|
|
404
|
+
for txn in sorted(journal.transactions, key=lambda t: t.date):
|
|
405
|
+
for posting in resolve_elision(txn):
|
|
406
|
+
if not _posting_matches(posting, txn, query):
|
|
407
|
+
continue
|
|
408
|
+
if posting.amount is None:
|
|
409
|
+
continue
|
|
410
|
+
running += posting.amount.quantity
|
|
411
|
+
rows.append(RegisterRow(
|
|
412
|
+
date=txn.date,
|
|
413
|
+
description=txn.description,
|
|
414
|
+
account=posting.account,
|
|
415
|
+
amount=posting.amount,
|
|
416
|
+
running_balance=running,
|
|
417
|
+
))
|
|
418
|
+
return RegisterResult(rows, journal.commodity_styles)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def stats(journal: Journal, query: Query | None = None) -> JournalStats:
|
|
422
|
+
"""Return summary statistics for the journal.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
journal: The parsed journal.
|
|
426
|
+
query: Optional filter. When None or Query(), behaviour is identical to
|
|
427
|
+
the original implementation (all transactions). When a date or
|
|
428
|
+
payee filter is provided, statistics are computed over the
|
|
429
|
+
matching transaction subset.
|
|
430
|
+
|
|
431
|
+
Note: account-level filters (account, not_account, depth) in the query are
|
|
432
|
+
not yet applied to stats fields — those fields still reflect the full
|
|
433
|
+
journal.
|
|
434
|
+
# TODO: Apply account-level query filters to account_count and account_depth.
|
|
435
|
+
"""
|
|
436
|
+
today = datetime.date.today()
|
|
437
|
+
txns = journal.transactions
|
|
438
|
+
|
|
439
|
+
# Apply transaction-level query filters when provided.
|
|
440
|
+
if query is not None:
|
|
441
|
+
txns = [
|
|
442
|
+
t for t in txns
|
|
443
|
+
if (query.date_from is None or t.date >= query.date_from)
|
|
444
|
+
and (query.date_to is None or t.date <= query.date_to)
|
|
445
|
+
and (query.payee is None or _matches_pattern(query.payee, t.description))
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
all_accounts: set[str] = {p.account for t in txns for p in t.postings}
|
|
449
|
+
all_commodities: set[str] = {
|
|
450
|
+
p.amount.commodity for t in txns for p in t.postings if p.amount is not None
|
|
451
|
+
}
|
|
452
|
+
dates = [t.date for t in txns]
|
|
453
|
+
date_range = (min(dates), max(dates)) if dates else None
|
|
454
|
+
last_txn_date = max(dates) if dates else None
|
|
455
|
+
span_days = (date_range[1] - date_range[0]).days + 1 if date_range else None
|
|
456
|
+
|
|
457
|
+
cutoff_30 = today - datetime.timedelta(days=30)
|
|
458
|
+
cutoff_7 = today - datetime.timedelta(days=7)
|
|
459
|
+
txns_30 = sum(1 for t in txns if t.date >= cutoff_30)
|
|
460
|
+
txns_7 = sum(1 for t in txns if t.date >= cutoff_7)
|
|
461
|
+
|
|
462
|
+
depth = max((len(a.split(":")) for a in all_accounts), default=0)
|
|
463
|
+
|
|
464
|
+
return JournalStats(
|
|
465
|
+
source_file=journal.source_file,
|
|
466
|
+
included_files=journal.included_files,
|
|
467
|
+
transaction_count=len(txns),
|
|
468
|
+
date_range=date_range,
|
|
469
|
+
last_txn_date=last_txn_date,
|
|
470
|
+
last_txn_days_ago=(today - last_txn_date).days if last_txn_date else None,
|
|
471
|
+
txns_span_days=span_days,
|
|
472
|
+
txns_per_day=len(txns) / span_days if span_days else 0.0,
|
|
473
|
+
txns_last_30_days=txns_30,
|
|
474
|
+
txns_per_day_last_30=txns_30 / 30,
|
|
475
|
+
txns_last_7_days=txns_7,
|
|
476
|
+
txns_per_day_last_7=txns_7 / 7,
|
|
477
|
+
payee_count=len({t.description for t in txns}),
|
|
478
|
+
account_count=len(all_accounts),
|
|
479
|
+
account_depth=depth,
|
|
480
|
+
commodity_count=len(all_commodities),
|
|
481
|
+
commodities=sorted(all_commodities),
|
|
482
|
+
price_count=len(journal.prices),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def balance_from_spec(
|
|
487
|
+
journal: Journal,
|
|
488
|
+
spec: ReportSpec,
|
|
489
|
+
query: Query | None = None,
|
|
490
|
+
) -> list[ReportSectionResult]:
|
|
491
|
+
"""Compute a structured balance report driven by a ReportSpec.
|
|
492
|
+
|
|
493
|
+
For each section in spec.sections:
|
|
494
|
+
1. Apply the outer query's date and payee filters at transaction level.
|
|
495
|
+
2. Include postings whose account matches any of section.accounts (OR logic).
|
|
496
|
+
3. Exclude postings whose account matches any of section.exclude.
|
|
497
|
+
4. Apply the outer query's account/not_account filters if set.
|
|
498
|
+
5. Apply depth truncation: section.depth overrides query.depth.
|
|
499
|
+
6. Aggregate using _aggregate_posting_amounts (shared with balance()).
|
|
500
|
+
7. Apply sign inversion if section.invert is True.
|
|
501
|
+
8. Return a ReportSectionResult per section.
|
|
502
|
+
|
|
503
|
+
The outer query acts as a uniform time/payee filter across all sections.
|
|
504
|
+
Section-level account patterns are OR-combined within each section.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
journal: The parsed journal.
|
|
508
|
+
spec: The report layout definition.
|
|
509
|
+
query: Optional uniform filter (date range, payee). Applied across all
|
|
510
|
+
sections before section-level account matching.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
One ReportSectionResult per section in spec.sections order.
|
|
514
|
+
"""
|
|
515
|
+
results: list[ReportSectionResult] = []
|
|
516
|
+
commodity_styles = journal.commodity_styles
|
|
517
|
+
|
|
518
|
+
for section in spec.sections:
|
|
519
|
+
pairs: list[tuple[str, Decimal]] = []
|
|
520
|
+
|
|
521
|
+
for txn in journal.transactions:
|
|
522
|
+
# Apply outer query transaction-level filters.
|
|
523
|
+
if query is not None:
|
|
524
|
+
if query.date_from is not None and txn.date < query.date_from:
|
|
525
|
+
continue
|
|
526
|
+
if query.date_to is not None and txn.date > query.date_to:
|
|
527
|
+
continue
|
|
528
|
+
if query.payee is not None and not _matches_pattern(query.payee, txn.description):
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
for posting in resolve_elision(txn):
|
|
532
|
+
# Section account patterns: OR logic — posting must match at least one.
|
|
533
|
+
if not any(_matches_pattern(pat, posting.account) for pat in section.accounts):
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
# Section exclude patterns: posting must not match any.
|
|
537
|
+
if any(_matches_pattern(pat, posting.account) for pat in section.exclude):
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
# Outer query account/not_account filters.
|
|
541
|
+
if query is not None:
|
|
542
|
+
if query.account is not None and not _matches_pattern(query.account, posting.account):
|
|
543
|
+
continue
|
|
544
|
+
if query.not_account is not None and _matches_pattern(query.not_account, posting.account):
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
if posting.amount is None:
|
|
548
|
+
continue
|
|
549
|
+
|
|
550
|
+
# Depth: section.depth overrides query.depth.
|
|
551
|
+
depth = section.depth if section.depth is not None else (
|
|
552
|
+
query.depth if query is not None else None
|
|
553
|
+
)
|
|
554
|
+
account = posting.account
|
|
555
|
+
if depth is not None:
|
|
556
|
+
account = ":".join(account.split(":")[:depth])
|
|
557
|
+
|
|
558
|
+
pairs.append((account, posting.amount.quantity))
|
|
559
|
+
|
|
560
|
+
rows = _aggregate_posting_amounts(pairs)
|
|
561
|
+
|
|
562
|
+
if section.invert:
|
|
563
|
+
rows = {k: -v for k, v in rows.items()}
|
|
564
|
+
|
|
565
|
+
subtotal = sum(rows.values(), Decimal(0))
|
|
566
|
+
results.append(ReportSectionResult(
|
|
567
|
+
section=section,
|
|
568
|
+
rows=rows,
|
|
569
|
+
subtotal=subtotal,
|
|
570
|
+
_commodity_styles=commodity_styles,
|
|
571
|
+
))
|
|
572
|
+
|
|
573
|
+
return results
|
ledgerkit/writer.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Journal serialiser for ledgerkit.
|
|
2
|
+
|
|
3
|
+
Converts Transaction and Journal objects back to hledger journal-format text.
|
|
4
|
+
No file I/O; no imports from parser, loader, checks, or cli.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ledgerkit.models import Amount, Journal, Transaction
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _fmt_amount(amount: Amount) -> str:
|
|
13
|
+
"""Format an Amount to its hledger journal string representation.
|
|
14
|
+
|
|
15
|
+
Prefix-symbol style (£, $, €, ...) for non-alpha commodities; suffix-symbol
|
|
16
|
+
style (30.00 EUR) for alpha-initial commodity codes.
|
|
17
|
+
"""
|
|
18
|
+
qty = amount.quantity
|
|
19
|
+
sym = amount.commodity
|
|
20
|
+
if not sym:
|
|
21
|
+
return str(qty)
|
|
22
|
+
if sym[0].isalpha():
|
|
23
|
+
return f"{qty} {sym}"
|
|
24
|
+
if qty < 0:
|
|
25
|
+
return f"-{sym}{-qty}"
|
|
26
|
+
return f"{sym}{qty}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def transaction_to_text(txn: Transaction) -> str:
|
|
30
|
+
"""Serialise a Transaction to a journal-format string.
|
|
31
|
+
|
|
32
|
+
The returned string ends with a single newline. When parsed by
|
|
33
|
+
parse_string(), the result round-trips to an equivalent Transaction
|
|
34
|
+
(same date, flag, description, postings, amounts, comments;
|
|
35
|
+
source_span and raw_text are not checked).
|
|
36
|
+
"""
|
|
37
|
+
# --- Header line ---
|
|
38
|
+
parts: list[str] = [str(txn.date)]
|
|
39
|
+
if txn.cleared:
|
|
40
|
+
parts.append("*")
|
|
41
|
+
elif txn.pending:
|
|
42
|
+
parts.append("!")
|
|
43
|
+
if txn.code:
|
|
44
|
+
parts.append(f"({txn.code})")
|
|
45
|
+
if txn.description:
|
|
46
|
+
parts.append(txn.description)
|
|
47
|
+
header = " ".join(parts)
|
|
48
|
+
if txn.inline_comment is not None:
|
|
49
|
+
header += f" ; {txn.inline_comment}"
|
|
50
|
+
|
|
51
|
+
# --- Posting lines ---
|
|
52
|
+
# Build amount strings first to calculate alignment.
|
|
53
|
+
amount_strs: list[str | None] = [
|
|
54
|
+
_fmt_amount(p.amount) if p.amount is not None else None
|
|
55
|
+
for p in txn.postings
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Find the widest amount string and the widest account name (among postings
|
|
59
|
+
# that carry an explicit amount) to determine the alignment column.
|
|
60
|
+
max_amt_w = max((len(s) for s in amount_strs if s is not None), default=0)
|
|
61
|
+
acct_lens_with_amount = [
|
|
62
|
+
len(p.account)
|
|
63
|
+
for p, s in zip(txn.postings, amount_strs)
|
|
64
|
+
if s is not None
|
|
65
|
+
]
|
|
66
|
+
max_acct_w = max(acct_lens_with_amount, default=0)
|
|
67
|
+
# amount_col = position of the first character of the (right-justified)
|
|
68
|
+
# amount block, counting from the start of the posting line (4-space indent
|
|
69
|
+
# + longest account + minimum 2 spaces).
|
|
70
|
+
amount_col = 4 + max_acct_w + 2
|
|
71
|
+
|
|
72
|
+
posting_lines: list[str] = []
|
|
73
|
+
for posting, amt_str in zip(txn.postings, amount_strs):
|
|
74
|
+
if amt_str is None:
|
|
75
|
+
line = f" {posting.account}"
|
|
76
|
+
else:
|
|
77
|
+
gap = amount_col - 4 - len(posting.account)
|
|
78
|
+
line = f" {posting.account}{' ' * gap}{amt_str.rjust(max_amt_w)}"
|
|
79
|
+
if posting.inline_comment is not None:
|
|
80
|
+
line += f" ; {posting.inline_comment}"
|
|
81
|
+
posting_lines.append(line)
|
|
82
|
+
|
|
83
|
+
return "\n".join([header] + posting_lines) + "\n"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def journal_to_text(journal: Journal) -> str:
|
|
87
|
+
"""Serialise all transactions in journal.transactions to text.
|
|
88
|
+
|
|
89
|
+
Transactions are separated by blank lines; the output ends with a single
|
|
90
|
+
newline. Directives (account, commodity, payee, P) are not serialised in
|
|
91
|
+
v1 — callers that need full round-trip fidelity should work with the raw
|
|
92
|
+
source file rather than re-serialising the Journal.
|
|
93
|
+
"""
|
|
94
|
+
if not journal.transactions:
|
|
95
|
+
return "\n"
|
|
96
|
+
blocks = [transaction_to_text(t).rstrip("\n") for t in journal.transactions]
|
|
97
|
+
return "\n\n".join(blocks) + "\n"
|