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/cli.py ADDED
@@ -0,0 +1,448 @@
1
+ """Command-line interface for ledgerkit.
2
+
3
+ Parses arguments, loads the journal, calls reports, and formats output.
4
+ Entry point: main() — wired to `ledgerkit` via pyproject.toml.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import contextlib
11
+ import os
12
+ import sys
13
+ import time
14
+ from decimal import Decimal
15
+ from pathlib import Path
16
+
17
+ # Captured as early as possible so elapsed time includes import overhead.
18
+ _PROGRAM_START = time.perf_counter()
19
+
20
+ from ledgerkit import __version__
21
+
22
+
23
+ COMMANDS = ("balance", "register", "accounts", "print", "stats", "check")
24
+
25
+ _ANSI_RED = "\033[31m"
26
+ _ANSI_RESET = "\033[0m"
27
+
28
+
29
+ # Formats a quantity+commodity pair. When a CommodityStyle is provided it
30
+ # takes priority; otherwise falls back to hledger-style prefix+comma format.
31
+ def _fmt_amount(quantity: Decimal, commodity: str, style: object = None) -> str:
32
+ if style is not None:
33
+ return style.format(quantity) # type: ignore[attr-defined]
34
+ if quantity < 0:
35
+ return f"{commodity}-{abs(quantity):,.2f}"
36
+ return f"{commodity}{quantity:,.2f}"
37
+
38
+
39
+ # Formats the running balance; shows bare "0" (no commodity) when zero,
40
+ # matching hledger's convention for balanced-transaction nets.
41
+ def _fmt_balance(balance: Decimal, commodity: str, style: object = None) -> str:
42
+ if balance == 0:
43
+ return "0"
44
+ return _fmt_amount(balance, commodity, style)
45
+
46
+
47
+ # Abbreviates a colon-separated account name to fit within max_width by
48
+ # progressively shortening leading components (never the last component).
49
+ # First pass: shorten each oversized leading component to 2 chars.
50
+ # Second pass: shorten to 1 char if still too long.
51
+ # Matches hledger's visual shortening — e.g. "expenses:food:rent" → "ex:food:rent".
52
+ def _abbreviate_account(account: str, max_width: int = 20) -> str:
53
+ if len(account) <= max_width:
54
+ return account
55
+ parts = account.split(":")
56
+ for min_len in (2, 1):
57
+ for i in range(len(parts) - 1):
58
+ if len(parts[i]) > min_len:
59
+ parts[i] = parts[i][:min_len]
60
+ if len(":".join(parts)) <= max_width:
61
+ return ":".join(parts)
62
+ return ":".join(parts)
63
+
64
+
65
+
66
+ def _build_parser() -> argparse.ArgumentParser:
67
+ p = argparse.ArgumentParser(
68
+ prog="ledgerkit",
69
+ description="Plain-text accounting (hledger-compatible)",
70
+ )
71
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
72
+ p.add_argument(
73
+ "-v", "--verbose",
74
+ action="store_true",
75
+ help="Show more detailed output (stats: commodity names)",
76
+ )
77
+ p.add_argument(
78
+ "-1",
79
+ dest="one_line",
80
+ action="store_true",
81
+ help="Show a single tab-separated line of output (stats only)",
82
+ )
83
+ p.add_argument(
84
+ "-o", "--output-file",
85
+ metavar="FILE",
86
+ help="Write output to FILE instead of stdout",
87
+ )
88
+ p.add_argument(
89
+ "-f", "--file",
90
+ metavar="FILE",
91
+ dest="files",
92
+ action="append",
93
+ help="Read data from FILE, or stdin if -. May be specified more than once.",
94
+ )
95
+ p.add_argument(
96
+ "-s", "--strict",
97
+ action="store_true",
98
+ help="Enable strict mode: also check that accounts and commodities are declared",
99
+ )
100
+ p.add_argument(
101
+ "-I", "--ignore-assertions",
102
+ dest="ignore_assertions",
103
+ action="store_true",
104
+ help="Disable balance assertion checking",
105
+ )
106
+ p.add_argument(
107
+ "-c", "--commodity-style",
108
+ dest="commodity_styles_override",
109
+ action="append",
110
+ metavar="STYLE",
111
+ help=(
112
+ "Override display style for a commodity, e.g. '$1,000.00' or "
113
+ "'1.000,00 EUR'. May be specified more than once."
114
+ ),
115
+ )
116
+ p.add_argument(
117
+ "command",
118
+ choices=COMMANDS,
119
+ help=f"Report to run: {', '.join(COMMANDS)}",
120
+ )
121
+ p.add_argument(
122
+ "journal",
123
+ nargs="*",
124
+ help=(
125
+ "Journal file (shorthand for -f; all commands except check), "
126
+ "or check names (check command only)"
127
+ ),
128
+ )
129
+ return p
130
+
131
+
132
+ def _resolve_files(args: argparse.Namespace) -> list[str]:
133
+ """Return the ordered list of files to load from parsed CLI arguments.
134
+
135
+ Resolution order:
136
+ 1. ``-f``/``--file`` flags (all of them, in order given)
137
+ 2. Positional arguments when command is not ``check`` (backward-compatible
138
+ shorthand for a single journal file)
139
+ 3. ``$LEDGER_FILE`` environment variable
140
+ 4. ``~/.hledger.journal`` if it exists
141
+
142
+ For the ``check`` command all positional arguments are treated as check names,
143
+ not journal files; the journal file must come from ``-f`` or the environment.
144
+
145
+ Raises:
146
+ SystemExit(1): if both ``-f`` and a positional file are given, or if no
147
+ file can be determined.
148
+ """
149
+ is_check = getattr(args, "command", None) == "check"
150
+ # args.journal is a list (nargs="*"); non-empty only when positionals given
151
+ positional_file = args.journal[0] if (args.journal and not is_check) else None
152
+
153
+ if args.files and positional_file:
154
+ print(
155
+ "ledgerkit: cannot combine -f/--file flags with a positional "
156
+ "journal argument",
157
+ file=sys.stderr,
158
+ )
159
+ raise SystemExit(1)
160
+ if args.files:
161
+ return args.files
162
+ if positional_file:
163
+ return [positional_file]
164
+ env = os.environ.get("LEDGER_FILE")
165
+ if env:
166
+ return [env]
167
+ default = Path.home() / ".hledger.journal"
168
+ if default.exists():
169
+ return [str(default)]
170
+ print(
171
+ "ledgerkit: no journal file specified; use -f FILE or set $LEDGER_FILE",
172
+ file=sys.stderr,
173
+ )
174
+ raise SystemExit(1)
175
+
176
+
177
+ def main(argv: list[str] | None = None) -> int:
178
+ """Entry point for the ledgerkit CLI.
179
+
180
+ Args:
181
+ argv: Argument list (defaults to sys.argv[1:] when None).
182
+
183
+ Returns:
184
+ Exit code (0 = success, 1 = error).
185
+ """
186
+ parser = _build_parser()
187
+ args = parser.parse_args(argv)
188
+
189
+ try:
190
+ file_list = _resolve_files(args)
191
+ except SystemExit as exc:
192
+ return int(exc.code) if exc.code is not None else 1
193
+
194
+ try:
195
+ from ledgerkit.loader import load_journal, load_journal_stdin, merge_journals
196
+ from ledgerkit.models import Journal
197
+
198
+ loaded: list[Journal] = []
199
+ for f in file_list:
200
+ if f == "-":
201
+ loaded.append(load_journal_stdin())
202
+ else:
203
+ loaded.append(load_journal(f))
204
+ journal = merge_journals(loaded)
205
+ except FileNotFoundError as exc:
206
+ print(f"ledgerkit: file not found: {exc}", file=sys.stderr)
207
+ return 1
208
+ except Exception as exc:
209
+ print(f"ledgerkit: {exc}", file=sys.stderr)
210
+ return 1
211
+
212
+ # --- Build commodity styles map (inferred + -c overrides) ---
213
+ from ledgerkit.commodity_style import CommodityStyle as _CommodityStyle
214
+ commodity_styles: dict = journal.commodity_styles
215
+ for override_str in (getattr(args, "commodity_styles_override", None) or []):
216
+ try:
217
+ override = _CommodityStyle.parse_style_override(override_str)
218
+ commodity_styles[override.commodity] = override
219
+ except ValueError as exc:
220
+ print(f"ledgerkit: invalid -c value {override_str!r}: {exc}", file=sys.stderr)
221
+ return 1
222
+
223
+ # --- Default basic-check gate (runs before every command) ---
224
+ from ledgerkit import checks as _checks
225
+
226
+ _skip: frozenset[str] = (
227
+ frozenset({"assertions"}) if getattr(args, "ignore_assertions", False) else frozenset()
228
+ )
229
+
230
+ basic_errors = _checks.run_basic_checks(journal, skip=_skip)
231
+ if basic_errors:
232
+ for e in basic_errors:
233
+ print(f"ledgerkit: {e.message}", file=sys.stderr)
234
+ return 1
235
+
236
+ # --- Strict-mode checks (-s/--strict) ---
237
+ if args.strict:
238
+ strict_only = [
239
+ e for e in _checks.run_strict_checks(journal, skip=_skip)
240
+ if e.check_name not in _checks.BASIC_CHECK_NAMES
241
+ ]
242
+ if strict_only:
243
+ for e in strict_only:
244
+ print(f"ledgerkit: {e.message}", file=sys.stderr)
245
+ return 1
246
+
247
+ # --- check command (no output on success; errors → stderr + exit 1) ---
248
+ if args.command == "check":
249
+ check_names = args.journal # positional args are check names for this command
250
+ try:
251
+ errors = _checks.run_checks(
252
+ journal, names=list(check_names), strict=args.strict, skip=_skip
253
+ )
254
+ except ValueError as exc:
255
+ print(f"ledgerkit: {exc}", file=sys.stderr)
256
+ return 1
257
+ if errors:
258
+ for e in errors:
259
+ print(f"ledgerkit: {e.message}", file=sys.stderr)
260
+ return 1
261
+ return 0
262
+
263
+ outfile = None
264
+ if args.output_file:
265
+ try:
266
+ outfile = open(args.output_file, "w", encoding="utf-8")
267
+ except OSError as exc:
268
+ print(f"ledgerkit: cannot open output file: {exc}", file=sys.stderr)
269
+ return 1
270
+
271
+ try:
272
+ import ledgerkit.reports as reports
273
+
274
+ with contextlib.redirect_stdout(outfile) if outfile else contextlib.nullcontext():
275
+ if args.command == "balance":
276
+ result = reports.balance(journal)
277
+ # result: dict[str, dict[str, Decimal]] — account → commodity → net
278
+
279
+ # Flatten to (account, commodity, qty) rows; sorted alphabetically.
280
+ # Zero-balance commodity lines are omitted (matching hledger behaviour).
281
+ lines: list[tuple[str, str, Decimal]] = [
282
+ (acct, comm, qty)
283
+ for acct in sorted(result)
284
+ for comm, qty in sorted(result[acct].items())
285
+ if qty != 0
286
+ ]
287
+
288
+ if not lines:
289
+ pass
290
+ else:
291
+ # Per-commodity grand totals (from filtered lines only).
292
+ commodity_totals: dict[str, Decimal] = {}
293
+ for _, comm, qty in lines:
294
+ commodity_totals[comm] = commodity_totals.get(comm, Decimal(0)) + qty
295
+
296
+ formatted_amts = [
297
+ _fmt_amount(qty, comm, commodity_styles.get(comm))
298
+ for _, comm, qty in lines
299
+ ]
300
+ # Only show non-zero commodity totals; a single bare "0" when all net zero.
301
+ nonzero_totals = [
302
+ (comm, qty)
303
+ for comm, qty in sorted(commodity_totals.items())
304
+ if qty != 0
305
+ ]
306
+ total_strs = [
307
+ _fmt_amount(qty, comm, commodity_styles.get(comm))
308
+ for comm, qty in nonzero_totals
309
+ ]
310
+ col_w = max(
311
+ 20,
312
+ *(len(s) for s in formatted_amts),
313
+ *(len(s) for s in total_strs),
314
+ )
315
+
316
+ # Determine the last (account, commodity) row index per account
317
+ # so the account name is printed only on that row.
318
+ acct_last_idx: dict[str, int] = {
319
+ acct: i for i, (acct, _, _) in enumerate(lines)
320
+ }
321
+
322
+ for i, ((acct, comm, qty), amt_str) in enumerate(
323
+ zip(lines, formatted_amts)
324
+ ):
325
+ padded = f"{amt_str:>{col_w}}"
326
+ if qty < 0:
327
+ padded = f"{_ANSI_RED}{padded}{_ANSI_RESET}"
328
+ if i == acct_last_idx[acct]:
329
+ print(f"{padded} {acct}")
330
+ else:
331
+ print(padded)
332
+
333
+ print("-" * col_w)
334
+ if nonzero_totals:
335
+ for (comm, total), tot_str in zip(nonzero_totals, total_strs):
336
+ tot = f"{tot_str:>{col_w}}"
337
+ if total < 0:
338
+ tot = f"{_ANSI_RED}{tot}{_ANSI_RESET}"
339
+ print(tot)
340
+ else:
341
+ print(f"{'0':>{col_w}}")
342
+
343
+ elif args.command == "register":
344
+ rows = reports.register(journal)
345
+ prev_key: tuple | None = None
346
+ for row in rows:
347
+ cur_key = (row.date, row.description)
348
+ is_first = cur_key != prev_key
349
+ date_str = str(row.date) if is_first else ""
350
+ desc_str = row.description if is_first else ""
351
+ acct_str = _abbreviate_account(row.account)
352
+ commodity = row.amount.commodity
353
+ style = commodity_styles.get(commodity)
354
+
355
+ # Right-align before colorising — ANSI escape codes have nonzero
356
+ # string length but zero display width, so padding must come first.
357
+ amt_str = f"{_fmt_amount(row.amount.quantity, commodity, style):>12}"
358
+ if row.amount.quantity < 0:
359
+ amt_str = f"{_ANSI_RED}{amt_str}{_ANSI_RESET}"
360
+
361
+ bal_str = f"{_fmt_balance(row.running_balance, commodity, style):>13}"
362
+ if row.running_balance < 0:
363
+ bal_str = f"{_ANSI_RED}{bal_str}{_ANSI_RESET}"
364
+
365
+ print(
366
+ f"{date_str:10} {desc_str:21} {acct_str:<22} {amt_str} {bal_str}"
367
+ )
368
+ prev_key = cur_key
369
+
370
+ elif args.command == "accounts":
371
+ for name in reports.accounts(journal):
372
+ print(name)
373
+
374
+ elif args.command == "print":
375
+ for txn in sorted(journal.transactions, key=lambda t: t.date):
376
+ flag = " * " if txn.cleared else " ! " if txn.pending else " "
377
+ print(f"{txn.date}{flag}{txn.description}")
378
+ for posting in txn.postings:
379
+ if posting.amount:
380
+ amt_style = commodity_styles.get(posting.amount.commodity)
381
+ amt_txt = _fmt_amount(
382
+ posting.amount.quantity,
383
+ posting.amount.commodity,
384
+ amt_style,
385
+ )
386
+ print(f" {posting.account:<40} {amt_txt}")
387
+ else:
388
+ print(f" {posting.account}")
389
+ print()
390
+
391
+ elif args.command == "stats":
392
+ s = reports.stats(journal)
393
+ elapsed = time.perf_counter() - _PROGRAM_START
394
+ txns_per_s = s.transaction_count / elapsed if elapsed > 0 else 0.0
395
+
396
+ span_str = (
397
+ f"{s.date_range[0]} to {s.date_range[1]} ({s.txns_span_days} days)"
398
+ if s.date_range else "(none)"
399
+ )
400
+ last_str = (
401
+ f"{s.last_txn_date} ({s.last_txn_days_ago} days ago)"
402
+ if s.last_txn_date else "(none)"
403
+ )
404
+ commodity_str = (
405
+ f"{s.commodity_count} ({', '.join(s.commodities)})"
406
+ if args.verbose and s.commodities else str(s.commodity_count)
407
+ )
408
+
409
+ elapsed_str = f"{elapsed:.2f}" if elapsed >= 0.01 else f"{elapsed:.3f}"
410
+ if args.one_line:
411
+ print("\t".join([
412
+ __version__,
413
+ s.source_file or "(none)",
414
+ f"{elapsed_str} s elapsed",
415
+ f"{txns_per_s:.0f} txns/s",
416
+ ]))
417
+ else:
418
+ print(f"Main file : {s.source_file or '(none)'}")
419
+ print(f"Included files : {s.included_files}")
420
+ print(f"Txns span : {span_str}")
421
+ print(f"Last txn : {last_str}")
422
+ print(f"Txns : {s.transaction_count} ({s.txns_per_day:.1f} per day)")
423
+ print(f"Txns last 30 days : {s.txns_last_30_days} ({s.txns_per_day_last_30:.1f} per day)")
424
+ print(f"Txns last 7 days : {s.txns_last_7_days} ({s.txns_per_day_last_7:.1f} per day)")
425
+ print(f"Payees/descriptions : {s.payee_count}")
426
+ print(f"Accounts : {s.account_count} (depth {s.account_depth})")
427
+ print(f"Commodities : {commodity_str}")
428
+ print(f"Market prices : {s.price_count}")
429
+ print(f"Runtime stats : {elapsed_str} s elapsed, {txns_per_s:.0f} txns/s")
430
+
431
+ except NotImplementedError:
432
+ print(
433
+ f"ledgerkit: '{args.command}' is not yet implemented",
434
+ file=sys.stderr,
435
+ )
436
+ return 1
437
+ except Exception as exc:
438
+ print(f"ledgerkit: {exc}", file=sys.stderr)
439
+ return 1
440
+ finally:
441
+ if outfile:
442
+ outfile.close()
443
+
444
+ return 0
445
+
446
+
447
+ if __name__ == "__main__":
448
+ sys.exit(main())