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 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,12 @@
1
+ """Enables `python -m ledgerkit` invocation.
2
+
3
+ Usage:
4
+ python -m ledgerkit <command> <journal-file>
5
+
6
+ This is equivalent to running the `ledgerkit` CLI entry point directly.
7
+ """
8
+
9
+ import sys
10
+ from ledgerkit.cli import main
11
+
12
+ sys.exit(main())
@@ -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