bankstatementparser 0.0.9__tar.gz → 0.0.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/PKG-INFO +46 -12
  2. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/README.md +45 -11
  3. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/_llm_common.py +1 -0
  4. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/additional_parsers.py +4 -0
  5. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/api.py +6 -3
  6. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/cli.py +11 -10
  7. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/enrichment/categorizer.py +9 -3
  8. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/evaluation.py +6 -0
  9. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/llm_extractor.py +6 -3
  10. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/ollama_direct.py +1 -1
  11. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/orchestrator.py +22 -3
  12. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/pdf_text.py +7 -5
  13. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/verification.py +35 -16
  14. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/vision.py +7 -5
  15. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/parallel.py +1 -1
  16. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/transaction_deduplicator.py +11 -0
  17. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/transaction_models.py +4 -0
  18. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/pyproject.toml +25 -3
  19. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/LICENSE +0 -0
  20. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/__init__.py +0 -0
  21. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/_amounts.py +0 -0
  22. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/bank_statement_parsers.py +0 -0
  23. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/base_parser.py +0 -0
  24. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/camt_parser.py +0 -0
  25. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/enrichment/__init__.py +0 -0
  26. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/enrichment/account_mapper.py +0 -0
  27. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/exceptions.py +0 -0
  28. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/export/__init__.py +0 -0
  29. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/export/ledger.py +0 -0
  30. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/__init__.py +0 -0
  31. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/prompts.py +0 -0
  32. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/hybrid/scanner.py +0 -0
  33. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/input_validator.py +0 -0
  34. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/pain001_parser.py +0 -0
  35. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/record_types.py +0 -0
  36. {bankstatementparser-0.0.9 → bankstatementparser-0.0.10}/bankstatementparser/zip_security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bankstatementparser
3
- Version: 0.0.9
3
+ Version: 0.0.10
4
4
  Summary: BankStatementParser is your essential tool for easy bank statement management. Designed with finance and treasury experts in mind, it offers a simple way to handle CAMT (ISO 20022) formats and more. Get quick, accurate insights from your financial data and spend less time on processing. It's the smart, hassle-free way to stay on top of your transactions.
5
5
  License: Apache Software License
6
6
  License-File: LICENSE
@@ -49,7 +49,7 @@ Description-Content-Type: text/markdown
49
49
 
50
50
  <p align="center">
51
51
  <a href="https://github.com/sebastienrousseau/bankstatementparser/actions"><img src="https://img.shields.io/github/actions/workflow/status/sebastienrousseau/bankstatementparser/quality-gates.yml?style=for-the-badge&logo=github" alt="Build" /></a>
52
- <a href="https://pypi.org/project/bankstatementparser/"><img src="https://img.shields.io/pypi/pyversions/bankstatementparser.svg?style=for-the-badge&v=0.0.9" alt="PyPI" /></a>
52
+ <a href="https://pypi.org/project/bankstatementparser/"><img src="https://img.shields.io/pypi/pyversions/bankstatementparser.svg?style=for-the-badge&v=0.0.10" alt="PyPI" /></a>
53
53
  <a href="https://pypi.org/project/bankstatementparser/"><img src="https://img.shields.io/pypi/dm/bankstatementparser.svg?style=for-the-badge" alt="PyPI Downloads" /></a>
54
54
  <a href="https://codecov.io/github/sebastienrousseau/bankstatementparser?branch=main"><img src="https://img.shields.io/codecov/c/github/sebastienrousseau/bankstatementparser?style=for-the-badge" alt="Codecov" /></a>
55
55
  <a href="LICENSE"><img src="https://img.shields.io/github/license/sebastienrousseau/bankstatementparser?style=for-the-badge" alt="License" /></a>
@@ -93,8 +93,9 @@ services unless they explicitly opt in.
93
93
  - [Command Line](#command-line) — console script and REST API
94
94
  - [Deduplication](#deduplication) — exact hashes + suspected matches
95
95
  - [Export](#export) — CSV, JSON, Excel, Polars, hledger, beancount
96
- - [Examples](#examples) — 22 runnable scripts (14 deterministic + 8 hybrid)
96
+ - [Examples](#examples) — 23 runnable scripts (14 deterministic + 9 hybrid)
97
97
  - [XML Tag Mapping](#xml-tag-mapping) — ISO 20022 tags to DataFrame columns
98
+ - [Ecosystem](#ecosystem) — companion packages (MCP, LSP, writers, loaders)
98
99
 
99
100
  **Operational**
100
101
 
@@ -230,7 +231,7 @@ print(result.source_method) # "deterministic"
230
231
  # Path B — text-LLM for digital PDFs (set BSP_HYBRID_MODEL=ollama/llama3)
231
232
  result = smart_ingest("statement.pdf")
232
233
  print(result.source_method) # "llm"
233
- print(result.verification.status) # VERIFIED | DISCREPANCY | FAILED
234
+ print(result.verification.status) # VERIFIED | DISCREPANCY | UNVERIFIABLE | FAILED
234
235
 
235
236
  # Path C — multimodal vision for scanned PDFs (set BSP_HYBRID_VISION_MODEL)
236
237
  # auto-routed when pypdf cannot extract enough text
@@ -311,7 +312,7 @@ flowchart TD
311
312
  Z --> V[verify_balance<br/>Golden Rule]
312
313
  Y --> V
313
314
  X --> V
314
- V --> R[VERIFIED / DISCREPANCY / FAILED]
315
+ V --> R[VERIFIED / DISCREPANCY / UNVERIFIABLE / FAILED]
315
316
  ```
316
317
 
317
318
  Every extracted row carries an immutable `transaction_hash`, an
@@ -349,7 +350,7 @@ for the full surface.
349
350
 
350
351
  | Feature | Description |
351
352
  |---|---|
352
- | **Golden Rule verification** *(v0.0.5)* | Every result carries `opening + credits − debits == closing` status: `VERIFIED`, `DISCREPANCY`, or `FAILED`. |
353
+ | **Golden Rule verification** *(v0.0.5)* | Every result carries `opening + credits − debits == closing` status: `VERIFIED`, `DISCREPANCY`, `UNVERIFIABLE`, or `FAILED`. |
353
354
  | **Multi-currency verification** *(v0.0.8)* | `verify_balance_multi_currency()` groups transactions by currency and runs the Golden Rule independently per group — no more false `DISCREPANCY` on multi-currency statements. |
354
355
  | **Idempotent dedup** *(v0.0.5)* | Every `Transaction` carries a stable `transaction_hash` (MD5 of date + normalized description + amount). `Deduplicator.dedupe_by_hash()` makes incremental ingestion safe to re-run. |
355
356
  | **Interactive review** *(v0.0.6)* | `--type review` CLI walks through discrepancies with accept/edit/skip/delete/quit. `IngestResult.to_json()` / `.from_json()` for stable round-trip with embedded audit trail. |
@@ -370,7 +371,7 @@ for the full surface.
370
371
  |---|---|
371
372
  | **PII redaction** | Names, IBANs, and addresses masked by default — opt in with `--show-pii` |
372
373
  | **Secure ZIP** | `iter_secure_xml_entries()` rejects zip bombs, encrypted entries, and suspicious compression ratios |
373
- | **Tested** | 818 tests, coverage gated at 100% in CI, property-based fuzzing with Hypothesis |
374
+ | **Tested** | 844 tests, coverage gated at 100% in CI, property-based fuzzing with Hypothesis |
374
375
 
375
376
  ---
376
377
 
@@ -631,8 +632,8 @@ for currency, v in results.items():
631
632
 
632
633
  ## Examples
633
634
 
634
- See [`examples/`](examples/README.md) for 22 runnable scripts
635
- (14 deterministic + 8 hybrid):
635
+ See [`examples/`](examples/README.md) for 23 runnable scripts
636
+ (14 deterministic + 9 hybrid):
636
637
 
637
638
  ### Deterministic parsers
638
639
 
@@ -661,9 +662,11 @@ See [`examples/`](examples/README.md) for 22 runnable scripts
661
662
  | `hybrid/01_smart_ingest_deterministic.py` | Path A — `smart_ingest()` against a CAMT.053 fixture, $0 cost |
662
663
  | `hybrid/02_smart_ingest_text_llm.py` | Path B — text-LLM extraction from a digital PDF (mock or live Ollama) |
663
664
  | `hybrid/03_smart_ingest_vision.py` | Path C — multimodal vision extraction with `LOW_TEXT_DENSITY` auto-routing |
664
- | `hybrid/04_golden_rule.py` | All three `verify_balance()` outcomes |
665
+ | `hybrid/04_golden_rule.py` | `verify_balance()`, `verify_transactions()`, and `verify_continuity()` across `VERIFIED` / `DISCREPANCY` / `UNVERIFIABLE` outcomes |
665
666
  | `hybrid/05_dedupe_recurring.py` | `normalize_description()` + `dedupe_by_hash()` for idempotent batching |
666
667
  | `hybrid/06_cli_walkthrough.sh` | Four flavours of the new `--type ingest` CLI subcommand |
668
+ | `hybrid/06_cli_walkthrough.ps1` | PowerShell sibling of the bash walkthrough (native Windows) |
669
+ | `hybrid/07_scan_and_ingest.py` | Bulk directory ingest with `scan_and_ingest()` — cross-file dedup + continuity check |
667
670
 
668
671
  See [`examples/hybrid/README.md`](examples/hybrid/README.md) for
669
672
  the full walkthrough including a Mermaid flow diagram, the
@@ -681,6 +684,37 @@ reconciliation pipelines.
681
684
 
682
685
  ---
683
686
 
687
+ ## Ecosystem
688
+
689
+ `bankstatementparser` is the core engine. Optional, independently
690
+ versioned companion packages build on it — each is its own repository
691
+ and Python package, so you install only what you need and the core
692
+ stays dependency-light.
693
+
694
+ | Package | PyPI | Role | What it adds |
695
+ |---|---|---|---|
696
+ | [`bankstatementparser-mcp`](https://github.com/sebastienrousseau/bankstatementparser-mcp) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-mcp.svg)](https://pypi.org/project/bankstatementparser-mcp/) | AI agents | [Model Context Protocol](https://modelcontextprotocol.io) server — exposes detect / parse / validate / summarize as tools for Claude Desktop and other LLM clients |
697
+ | [`bankstatementparser-lsp`](https://github.com/sebastienrousseau/bankstatementparser-lsp) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-lsp.svg)](https://pypi.org/project/bankstatementparser-lsp/) | Editors | Language Server with live MT940 diagnostics (missing tags, malformed balance / `:61:` lines) over [pygls](https://github.com/openlawlibrary/pygls) |
698
+ | [`bankstatementparser-writer-xlsx`](https://github.com/sebastienrousseau/bankstatementparser-writer-xlsx) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-writer-xlsx.svg)](https://pypi.org/project/bankstatementparser-writer-xlsx/) | Output | Write parsed transactions (DataFrame, `Transaction` list, or dicts) to a polished Excel `.xlsx` workbook |
699
+ | [`bankstatementparser-loader-mt942`](https://github.com/sebastienrousseau/bankstatementparser-loader-mt942) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-loader-mt942.svg)](https://pypi.org/project/bankstatementparser-loader-mt942/) | Input | Parse SWIFT **MT942** interim transaction reports into `Transaction` objects (a format the core does not read) |
700
+ | [`bankstatementparser-loader-bai2`](https://github.com/sebastienrousseau/bankstatementparser-loader-bai2) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-loader-bai2.svg)](https://pypi.org/project/bankstatementparser-loader-bai2/) | Input | Parse **BAI2** cash-management files into `Transaction` objects (a format the core does not read) |
701
+
702
+ Loaders turn an additional source format into the same unified
703
+ `Transaction` model; writers take parsed data back out to another
704
+ target. Each companion pins `bankstatementparser` as a dependency and
705
+ ships its own 100%-coverage test suite.
706
+
707
+ ```bash
708
+ # Mix and match: e.g. read an MT942 file, then export to Excel
709
+ pip install bankstatementparser-loader-mt942 bankstatementparser-writer-xlsx
710
+ ```
711
+
712
+ Loaders hand back the same `Transaction` model the core parsers
713
+ produce, so `load_mt942_file(...)` then `write_xlsx(...)` composes
714
+ cleanly — see each companion's README for runnable examples.
715
+
716
+ ---
717
+
684
718
  ## Project Layout
685
719
 
686
720
  ```text
@@ -690,9 +724,9 @@ bankstatementparser/enrichment/ Categorizer, AccountMapper, EnrichedTransaction
690
724
  bankstatementparser/export/ hledger + beancount journal export
691
725
  bankstatementparser/api.py REST API microservice (FastAPI)
692
726
  docs/compliance/ ISO 13485 validation, risk register, traceability matrix
693
- examples/ 14 deterministic + 8 hybrid runnable example scripts
727
+ examples/ 14 deterministic + 9 hybrid runnable example scripts
694
728
  scripts/ SBOM generation, checksums, signature verification
695
- tests/ 818 tests (unit, integration, property-based, security, hybrid mocks)
729
+ tests/ 844 tests (unit, integration, property-based, security, hybrid mocks)
696
730
  ```
697
731
 
698
732
  ---
@@ -10,7 +10,7 @@
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://github.com/sebastienrousseau/bankstatementparser/actions"><img src="https://img.shields.io/github/actions/workflow/status/sebastienrousseau/bankstatementparser/quality-gates.yml?style=for-the-badge&logo=github" alt="Build" /></a>
13
- <a href="https://pypi.org/project/bankstatementparser/"><img src="https://img.shields.io/pypi/pyversions/bankstatementparser.svg?style=for-the-badge&v=0.0.9" alt="PyPI" /></a>
13
+ <a href="https://pypi.org/project/bankstatementparser/"><img src="https://img.shields.io/pypi/pyversions/bankstatementparser.svg?style=for-the-badge&v=0.0.10" alt="PyPI" /></a>
14
14
  <a href="https://pypi.org/project/bankstatementparser/"><img src="https://img.shields.io/pypi/dm/bankstatementparser.svg?style=for-the-badge" alt="PyPI Downloads" /></a>
15
15
  <a href="https://codecov.io/github/sebastienrousseau/bankstatementparser?branch=main"><img src="https://img.shields.io/codecov/c/github/sebastienrousseau/bankstatementparser?style=for-the-badge" alt="Codecov" /></a>
16
16
  <a href="LICENSE"><img src="https://img.shields.io/github/license/sebastienrousseau/bankstatementparser?style=for-the-badge" alt="License" /></a>
@@ -54,8 +54,9 @@ services unless they explicitly opt in.
54
54
  - [Command Line](#command-line) — console script and REST API
55
55
  - [Deduplication](#deduplication) — exact hashes + suspected matches
56
56
  - [Export](#export) — CSV, JSON, Excel, Polars, hledger, beancount
57
- - [Examples](#examples) — 22 runnable scripts (14 deterministic + 8 hybrid)
57
+ - [Examples](#examples) — 23 runnable scripts (14 deterministic + 9 hybrid)
58
58
  - [XML Tag Mapping](#xml-tag-mapping) — ISO 20022 tags to DataFrame columns
59
+ - [Ecosystem](#ecosystem) — companion packages (MCP, LSP, writers, loaders)
59
60
 
60
61
  **Operational**
61
62
 
@@ -191,7 +192,7 @@ print(result.source_method) # "deterministic"
191
192
  # Path B — text-LLM for digital PDFs (set BSP_HYBRID_MODEL=ollama/llama3)
192
193
  result = smart_ingest("statement.pdf")
193
194
  print(result.source_method) # "llm"
194
- print(result.verification.status) # VERIFIED | DISCREPANCY | FAILED
195
+ print(result.verification.status) # VERIFIED | DISCREPANCY | UNVERIFIABLE | FAILED
195
196
 
196
197
  # Path C — multimodal vision for scanned PDFs (set BSP_HYBRID_VISION_MODEL)
197
198
  # auto-routed when pypdf cannot extract enough text
@@ -272,7 +273,7 @@ flowchart TD
272
273
  Z --> V[verify_balance<br/>Golden Rule]
273
274
  Y --> V
274
275
  X --> V
275
- V --> R[VERIFIED / DISCREPANCY / FAILED]
276
+ V --> R[VERIFIED / DISCREPANCY / UNVERIFIABLE / FAILED]
276
277
  ```
277
278
 
278
279
  Every extracted row carries an immutable `transaction_hash`, an
@@ -310,7 +311,7 @@ for the full surface.
310
311
 
311
312
  | Feature | Description |
312
313
  |---|---|
313
- | **Golden Rule verification** *(v0.0.5)* | Every result carries `opening + credits − debits == closing` status: `VERIFIED`, `DISCREPANCY`, or `FAILED`. |
314
+ | **Golden Rule verification** *(v0.0.5)* | Every result carries `opening + credits − debits == closing` status: `VERIFIED`, `DISCREPANCY`, `UNVERIFIABLE`, or `FAILED`. |
314
315
  | **Multi-currency verification** *(v0.0.8)* | `verify_balance_multi_currency()` groups transactions by currency and runs the Golden Rule independently per group — no more false `DISCREPANCY` on multi-currency statements. |
315
316
  | **Idempotent dedup** *(v0.0.5)* | Every `Transaction` carries a stable `transaction_hash` (MD5 of date + normalized description + amount). `Deduplicator.dedupe_by_hash()` makes incremental ingestion safe to re-run. |
316
317
  | **Interactive review** *(v0.0.6)* | `--type review` CLI walks through discrepancies with accept/edit/skip/delete/quit. `IngestResult.to_json()` / `.from_json()` for stable round-trip with embedded audit trail. |
@@ -331,7 +332,7 @@ for the full surface.
331
332
  |---|---|
332
333
  | **PII redaction** | Names, IBANs, and addresses masked by default — opt in with `--show-pii` |
333
334
  | **Secure ZIP** | `iter_secure_xml_entries()` rejects zip bombs, encrypted entries, and suspicious compression ratios |
334
- | **Tested** | 818 tests, coverage gated at 100% in CI, property-based fuzzing with Hypothesis |
335
+ | **Tested** | 844 tests, coverage gated at 100% in CI, property-based fuzzing with Hypothesis |
335
336
 
336
337
  ---
337
338
 
@@ -592,8 +593,8 @@ for currency, v in results.items():
592
593
 
593
594
  ## Examples
594
595
 
595
- See [`examples/`](examples/README.md) for 22 runnable scripts
596
- (14 deterministic + 8 hybrid):
596
+ See [`examples/`](examples/README.md) for 23 runnable scripts
597
+ (14 deterministic + 9 hybrid):
597
598
 
598
599
  ### Deterministic parsers
599
600
 
@@ -622,9 +623,11 @@ See [`examples/`](examples/README.md) for 22 runnable scripts
622
623
  | `hybrid/01_smart_ingest_deterministic.py` | Path A — `smart_ingest()` against a CAMT.053 fixture, $0 cost |
623
624
  | `hybrid/02_smart_ingest_text_llm.py` | Path B — text-LLM extraction from a digital PDF (mock or live Ollama) |
624
625
  | `hybrid/03_smart_ingest_vision.py` | Path C — multimodal vision extraction with `LOW_TEXT_DENSITY` auto-routing |
625
- | `hybrid/04_golden_rule.py` | All three `verify_balance()` outcomes |
626
+ | `hybrid/04_golden_rule.py` | `verify_balance()`, `verify_transactions()`, and `verify_continuity()` across `VERIFIED` / `DISCREPANCY` / `UNVERIFIABLE` outcomes |
626
627
  | `hybrid/05_dedupe_recurring.py` | `normalize_description()` + `dedupe_by_hash()` for idempotent batching |
627
628
  | `hybrid/06_cli_walkthrough.sh` | Four flavours of the new `--type ingest` CLI subcommand |
629
+ | `hybrid/06_cli_walkthrough.ps1` | PowerShell sibling of the bash walkthrough (native Windows) |
630
+ | `hybrid/07_scan_and_ingest.py` | Bulk directory ingest with `scan_and_ingest()` — cross-file dedup + continuity check |
628
631
 
629
632
  See [`examples/hybrid/README.md`](examples/hybrid/README.md) for
630
633
  the full walkthrough including a Mermaid flow diagram, the
@@ -642,6 +645,37 @@ reconciliation pipelines.
642
645
 
643
646
  ---
644
647
 
648
+ ## Ecosystem
649
+
650
+ `bankstatementparser` is the core engine. Optional, independently
651
+ versioned companion packages build on it — each is its own repository
652
+ and Python package, so you install only what you need and the core
653
+ stays dependency-light.
654
+
655
+ | Package | PyPI | Role | What it adds |
656
+ |---|---|---|---|
657
+ | [`bankstatementparser-mcp`](https://github.com/sebastienrousseau/bankstatementparser-mcp) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-mcp.svg)](https://pypi.org/project/bankstatementparser-mcp/) | AI agents | [Model Context Protocol](https://modelcontextprotocol.io) server — exposes detect / parse / validate / summarize as tools for Claude Desktop and other LLM clients |
658
+ | [`bankstatementparser-lsp`](https://github.com/sebastienrousseau/bankstatementparser-lsp) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-lsp.svg)](https://pypi.org/project/bankstatementparser-lsp/) | Editors | Language Server with live MT940 diagnostics (missing tags, malformed balance / `:61:` lines) over [pygls](https://github.com/openlawlibrary/pygls) |
659
+ | [`bankstatementparser-writer-xlsx`](https://github.com/sebastienrousseau/bankstatementparser-writer-xlsx) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-writer-xlsx.svg)](https://pypi.org/project/bankstatementparser-writer-xlsx/) | Output | Write parsed transactions (DataFrame, `Transaction` list, or dicts) to a polished Excel `.xlsx` workbook |
660
+ | [`bankstatementparser-loader-mt942`](https://github.com/sebastienrousseau/bankstatementparser-loader-mt942) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-loader-mt942.svg)](https://pypi.org/project/bankstatementparser-loader-mt942/) | Input | Parse SWIFT **MT942** interim transaction reports into `Transaction` objects (a format the core does not read) |
661
+ | [`bankstatementparser-loader-bai2`](https://github.com/sebastienrousseau/bankstatementparser-loader-bai2) | [![PyPI](https://img.shields.io/pypi/v/bankstatementparser-loader-bai2.svg)](https://pypi.org/project/bankstatementparser-loader-bai2/) | Input | Parse **BAI2** cash-management files into `Transaction` objects (a format the core does not read) |
662
+
663
+ Loaders turn an additional source format into the same unified
664
+ `Transaction` model; writers take parsed data back out to another
665
+ target. Each companion pins `bankstatementparser` as a dependency and
666
+ ships its own 100%-coverage test suite.
667
+
668
+ ```bash
669
+ # Mix and match: e.g. read an MT942 file, then export to Excel
670
+ pip install bankstatementparser-loader-mt942 bankstatementparser-writer-xlsx
671
+ ```
672
+
673
+ Loaders hand back the same `Transaction` model the core parsers
674
+ produce, so `load_mt942_file(...)` then `write_xlsx(...)` composes
675
+ cleanly — see each companion's README for runnable examples.
676
+
677
+ ---
678
+
645
679
  ## Project Layout
646
680
 
647
681
  ```text
@@ -651,9 +685,9 @@ bankstatementparser/enrichment/ Categorizer, AccountMapper, EnrichedTransaction
651
685
  bankstatementparser/export/ hledger + beancount journal export
652
686
  bankstatementparser/api.py REST API microservice (FastAPI)
653
687
  docs/compliance/ ISO 13485 validation, risk register, traceability matrix
654
- examples/ 14 deterministic + 8 hybrid runnable example scripts
688
+ examples/ 14 deterministic + 9 hybrid runnable example scripts
655
689
  scripts/ SBOM generation, checksums, signature verification
656
- tests/ 818 tests (unit, integration, property-based, security, hybrid mocks)
690
+ tests/ 844 tests (unit, integration, property-based, security, hybrid mocks)
657
691
  ```
658
692
 
659
693
  ---
@@ -56,6 +56,7 @@ _warned_destinations: set[tuple[str, str]] = set()
56
56
 
57
57
 
58
58
  def _is_local_api_base(api_base: Optional[str]) -> bool:
59
+ """Return whether an API base URL points at the local machine."""
59
60
  if not api_base:
60
61
  return True
61
62
  host = urlparse(api_base).hostname or ""
@@ -118,6 +118,7 @@ CSV_COLUMN_GROUPS = {
118
118
 
119
119
 
120
120
  def _normalized_name(name: str) -> str:
121
+ """Fold a header name to lowercase ASCII alphanumerics for matching."""
121
122
  # NFKD + ASCII-encode folds accented characters to their base
122
123
  # letter (é -> e) so French/Spanish headers match their
123
124
  # unaccented synonym entries.
@@ -130,6 +131,7 @@ def _normalized_name(name: str) -> str:
130
131
 
131
132
 
132
133
  def _read_validated_text(file_name: str | Path) -> tuple[Path, str]:
134
+ """Validate the path and read its contents as UTF-8 text."""
133
135
  validator = InputValidator()
134
136
  path = validator.validate_input_file_path(str(file_name))
135
137
  try:
@@ -210,6 +212,7 @@ class CsvStatementParser(BankStatementParser):
210
212
  self._parsed_df: pd.DataFrame | None = None
211
213
 
212
214
  def _find_column(self, df: pd.DataFrame, logical_name: str) -> str | None:
215
+ """Return the DataFrame column matching a logical name, if any."""
213
216
  candidates = CSV_COLUMN_GROUPS[logical_name]
214
217
  for column in df.columns:
215
218
  column_name = str(column)
@@ -351,6 +354,7 @@ class OfxParser(BankStatementParser):
351
354
  self._parsed_df: pd.DataFrame | None = None
352
355
 
353
356
  def _tag_value(self, source: str, tag: str) -> str | None:
357
+ """Return the stripped value of an OFX/SGML tag, or None."""
354
358
  match = re.search(rf"<{tag}>([^<\r\n]+)", source, flags=re.IGNORECASE)
355
359
  if match is None:
356
360
  return None
@@ -89,6 +89,7 @@ class APIError(RuntimeError):
89
89
 
90
90
 
91
91
  def _resolve_max_upload_bytes() -> int:
92
+ """Resolve the max upload size from the environment or default."""
92
93
  raw = os.environ.get(ENV_MAX_UPLOAD_BYTES)
93
94
  if not raw:
94
95
  return DEFAULT_MAX_UPLOAD_BYTES
@@ -121,6 +122,7 @@ def _safe_basename(filename: Optional[str]) -> str:
121
122
 
122
123
 
123
124
  def _allowed_suffix(name: str) -> bool:
125
+ """Return whether a filename has an allowed input extension."""
124
126
  suffix = Path(name).suffix
125
127
  if not suffix:
126
128
  return False
@@ -170,7 +172,7 @@ def create_app(
170
172
  _file_field = File(...)
171
173
 
172
174
  @app.post("/ingest") # type: ignore[untyped-decorator]
173
- async def ingest( # pragma: no cover - async endpoint needs ASGI server
175
+ async def ingest(
174
176
  file: UploadFile = _file_field,
175
177
  ) -> JSONResponse:
176
178
  """Upload a bank statement and get structured JSON back.
@@ -277,6 +279,7 @@ def _result_to_dict(result: Any) -> dict[str, Any]:
277
279
 
278
280
 
279
281
  def _verification_dict(v: Any) -> Optional[dict[str, Any]]:
282
+ """Serialize a verification result to a JSON-safe dict, or None."""
280
283
  if v is None:
281
284
  return None
282
285
  return {
@@ -319,5 +322,5 @@ def main() -> None:
319
322
  "Install with: pip install 'bankstatementparser[api]'"
320
323
  ) from exc
321
324
 
322
- app = create_app() # pragma: no cover - server entrypoint
323
- uvicorn.run(app, host=args.host, port=args.port) # pragma: no cover
325
+ app = create_app()
326
+ uvicorn.run(app, host=args.host, port=args.port)
@@ -173,6 +173,7 @@ class BankStatementCLI:
173
173
  """
174
174
 
175
175
  def get_camt_stats(parser: Any) -> list[dict[str, Any]]:
176
+ """Return parser statistics as a list of dict rows."""
176
177
  data = parser.get_statement_stats()
177
178
  if isinstance(data, pd.DataFrame):
178
179
  # list(DataFrame) would yield column names, not rows
@@ -375,7 +376,6 @@ class BankStatementCLI:
375
376
  )
376
377
  logger.error(f"Hybrid import failed: {exc}")
377
378
  sys.exit(1)
378
- return # pragma: no cover
379
379
 
380
380
  try:
381
381
  result = smart_ingest(str(file_path))
@@ -388,12 +388,10 @@ class BankStatementCLI:
388
388
  )
389
389
  logger.error(f"Hybrid runtime import failed: {exc}")
390
390
  sys.exit(1)
391
- return # pragma: no cover
392
391
  except Exception as exc:
393
392
  logger.error(f"Hybrid ingest failed: {exc}")
394
393
  print(f"Error: hybrid ingest failed - {exc}")
395
394
  sys.exit(1)
396
- return # pragma: no cover
397
395
 
398
396
  rows = [
399
397
  {
@@ -450,9 +448,11 @@ class BankStatementCLI:
450
448
  (when that file is JSON) or any other source that emits an
451
449
  :class:`~bankstatementparser.hybrid.IngestResult` via its
452
450
  ``to_json()`` method. When the statement-level status is
453
- :class:`VerificationStatus.DISCREPANCY` or
454
- :class:`VerificationStatus.FAILED`, every transaction is
455
- reviewed. Independently, ``review_below`` routes individual
451
+ :class:`VerificationStatus.DISCREPANCY`,
452
+ :class:`VerificationStatus.UNVERIFIABLE`, or
453
+ :class:`VerificationStatus.FAILED` (i.e. anything other than
454
+ ``VERIFIED``), every transaction is reviewed. Independently,
455
+ ``review_below`` routes individual
456
456
  low-confidence rows into the same flow even when the
457
457
  statement verified cleanly. The operator is prompted with a
458
458
  single-character action menu:
@@ -493,7 +493,6 @@ class BankStatementCLI:
493
493
  )
494
494
  logger.error(f"Hybrid import failed: {exc}")
495
495
  sys.exit(1)
496
- return # pragma: no cover
497
496
 
498
497
  try:
499
498
  payload = file_path.read_text(encoding="utf-8")
@@ -502,7 +501,6 @@ class BankStatementCLI:
502
501
  logger.error(f"Failed to load IngestResult: {exc}")
503
502
  print(f"Error: cannot load IngestResult JSON - {exc}")
504
503
  sys.exit(1)
505
- return # pragma: no cover
506
504
 
507
505
  v = result.verification
508
506
  review_all = v is not None and v.status is not (
@@ -756,10 +754,13 @@ class BankStatementCLI:
756
754
  try:
757
755
  args = self.parser.parse_args()
758
756
  except SystemExit:
759
- # argparse failed, which means required arguments are missing
757
+ # argparse failed, which means required arguments are missing.
758
+ # The explicit return keeps the function from falling through
759
+ # to the (now unbound) ``args`` reference when ``sys.exit`` is
760
+ # patched in tests; in production ``sys.exit`` raises first.
760
761
  print("Error: Missing required arguments")
761
762
  sys.exit(1)
762
- return # pragma: no cover
763
+ return
763
764
 
764
765
  # Check if required arguments are present (safety check)
765
766
  if (
@@ -178,6 +178,7 @@ def _build_messages(
178
178
  transactions: list[Transaction],
179
179
  schema: tuple[str, ...],
180
180
  ) -> list[dict[str, Any]]:
181
+ """Build the system/user message pair for a categorization call."""
181
182
  schema_block = "\n".join(f" - {c}" for c in schema)
182
183
  rows = "\n".join(
183
184
  _format_row(idx, tx) for idx, tx in enumerate(transactions)
@@ -218,6 +219,7 @@ def _sanitize_for_prompt(value: str) -> str:
218
219
 
219
220
 
220
221
  def _format_row(index: int, tx: Transaction) -> str:
222
+ """Render one transaction as a single prompt line."""
221
223
  date = tx.booking_date.isoformat() if tx.booking_date else "????-??-??"
222
224
  desc = _sanitize_for_prompt(tx.description or "(no description)")
223
225
  return f" [{index}] {date} {tx.amount:>10} {desc[:80]}"
@@ -331,6 +333,7 @@ class Categorizer:
331
333
  self,
332
334
  chunk: list[Transaction],
333
335
  ) -> list[EnrichedTransaction]:
336
+ """Categorize a single chunk of transactions via one LLM call."""
334
337
  completion = self._resolve_completion()
335
338
  messages = _build_messages(chunk, self.schema)
336
339
 
@@ -354,16 +357,17 @@ class Categorizer:
354
357
  return _build_enriched(chunk, payload, self._schema_lookup)
355
358
 
356
359
  def _resolve_completion(self) -> CompletionFn:
360
+ """Return the injected completion callable or import LiteLLM's."""
357
361
  if self.completion_fn is not None:
358
362
  return self.completion_fn
359
- try: # pragma: no cover - optional dep
363
+ try:
360
364
  from litellm import completion
361
- except ImportError as exc: # pragma: no cover - optional dep
365
+ except ImportError as exc:
362
366
  raise CategorizerError(
363
367
  "litellm is required for the enrichment module. "
364
368
  "Install with: pip install bankstatementparser[enrichment]"
365
369
  ) from exc
366
- return completion # type: ignore[no-any-return] # pragma: no cover
370
+ return completion # type: ignore[no-any-return]
367
371
 
368
372
 
369
373
  # ---------------------------------------------------------------------------
@@ -377,6 +381,7 @@ def _extract_message_content(response: Any) -> str:
377
381
 
378
382
 
379
383
  def _parse_json_payload(raw: str) -> dict[str, Any]:
384
+ """Tolerantly parse a JSON object from a model response."""
380
385
  return parse_json_payload(raw, error_cls=CategorizerError)
381
386
 
382
387
 
@@ -385,6 +390,7 @@ def _build_enriched(
385
390
  payload: dict[str, Any],
386
391
  schema_lookup: dict[str, str],
387
392
  ) -> list[EnrichedTransaction]:
393
+ """Map an LLM results payload back onto the input chunk by index."""
388
394
  raw_results = payload.get("results")
389
395
  if not isinstance(raw_results, list):
390
396
  raise CategorizerError("Enrichment payload missing 'results' list")
@@ -102,6 +102,7 @@ class EvalSummary:
102
102
 
103
103
 
104
104
  def _to_decimal(value: object, *, context: str) -> Decimal:
105
+ """Coerce a value to ``Decimal``, raising on malformed input."""
105
106
  try:
106
107
  return Decimal(str(value))
107
108
  except InvalidOperation as exc:
@@ -109,12 +110,14 @@ def _to_decimal(value: object, *, context: str) -> Decimal:
109
110
 
110
111
 
111
112
  def _optional_decimal(value: object, *, context: str) -> Optional[Decimal]:
113
+ """Coerce a value to ``Decimal``, returning ``None`` when empty."""
112
114
  if value is None or value == "":
113
115
  return None
114
116
  return _to_decimal(value, context=context)
115
117
 
116
118
 
117
119
  def _optional_date(value: object, *, context: str) -> Optional[date]:
120
+ """Parse an ISO date, returning ``None`` when empty and raising on bad input."""
118
121
  if value is None or value == "":
119
122
  return None
120
123
  try:
@@ -243,6 +246,7 @@ def _match_rows(
243
246
  def _description_matches(
244
247
  expected: Optional[str], actual: Optional[str]
245
248
  ) -> bool:
249
+ """Return whether two descriptions match after normalization."""
246
250
  if not expected:
247
251
  return not actual
248
252
  if not actual:
@@ -253,6 +257,7 @@ def _description_matches(
253
257
 
254
258
 
255
259
  def _ratio(numerator: int, denominator: int) -> float:
260
+ """Return ``numerator / denominator``, or 1.0 when the denominator is zero."""
256
261
  return numerator / denominator if denominator else 1.0
257
262
 
258
263
 
@@ -315,6 +320,7 @@ def score_extraction(
315
320
  )
316
321
 
317
322
  def _check(expected_value: object, actual_value: object) -> Optional[bool]:
323
+ """Compare values, returning ``None`` when nothing is pinned."""
318
324
  if expected_value is None:
319
325
  return None
320
326
  return expected_value == actual_value
@@ -127,6 +127,7 @@ class LLMExtractor:
127
127
  return _build_result(payload, raw, source_text=statement_text)
128
128
 
129
129
  def _resolve_completion(self) -> CompletionFn:
130
+ """Return the injected completion callable or import a backend."""
130
131
  if self._completion_fn is not None:
131
132
  return self._completion_fn
132
133
  # Auto-select the direct Ollama bridge for any ollama/* model
@@ -141,14 +142,14 @@ class LLMExtractor:
141
142
 
142
143
  if is_ollama_model(self.model):
143
144
  return ollama_direct_completion
144
- try: # pragma: no cover - optional dep
145
+ try:
145
146
  from litellm import completion
146
- except ImportError as exc: # pragma: no cover - optional dep
147
+ except ImportError as exc:
147
148
  raise LLMExtractorError(
148
149
  "litellm is required for LLM extraction. "
149
150
  "Install with: pip install bankstatementparser[hybrid]"
150
151
  ) from exc
151
- return completion # type: ignore[no-any-return] # pragma: no cover
152
+ return completion # type: ignore[no-any-return]
152
153
 
153
154
 
154
155
  def _extract_message_content(response: Any) -> str:
@@ -162,6 +163,7 @@ def _parse_json_payload(raw: str) -> dict[str, Any]:
162
163
 
163
164
 
164
165
  def _to_decimal(value: Any) -> Optional[Decimal]:
166
+ """Coerce a value to ``Decimal``, returning ``None`` when empty."""
165
167
  if value in (None, ""):
166
168
  return None
167
169
  try:
@@ -203,6 +205,7 @@ def _build_result(
203
205
  source_text: str = "",
204
206
  source_method: SourceMethod = "llm",
205
207
  ) -> LLMExtractionResult:
208
+ """Convert a parsed LLM payload into an :class:`LLMExtractionResult`."""
206
209
  raw_txs = payload.get("transactions") or []
207
210
  if not isinstance(raw_txs, list):
208
211
  raise LLMExtractorError("LLM 'transactions' field must be a list")
@@ -91,7 +91,7 @@ def ollama_direct_completion(**kwargs: Any) -> dict[str, Any]:
91
91
  """
92
92
  try:
93
93
  import httpx
94
- except ImportError as exc: # pragma: no cover - optional dep
94
+ except ImportError as exc:
95
95
  raise OllamaDirectError(
96
96
  "httpx is required for the direct Ollama bridge. "
97
97
  "Install with: pip install bankstatementparser[hybrid]"
@@ -109,6 +109,7 @@ class IngestResult:
109
109
  return json.dumps(self._to_dict(), indent=indent, sort_keys=False)
110
110
 
111
111
  def _to_dict(self) -> dict[str, Any]:
112
+ """Build the JSON-serializable dict representation."""
112
113
  return {
113
114
  "schema_version": 1,
114
115
  "source_method": self.source_method,
@@ -146,6 +147,7 @@ class IngestResult:
146
147
 
147
148
  @classmethod
148
149
  def _from_dict(cls, data: dict[str, Any]) -> IngestResult:
150
+ """Reconstruct an instance from a decoded JSON dict, validating fields."""
149
151
  version = data.get("schema_version")
150
152
  if version not in (None, 1):
151
153
  raise ValueError(
@@ -185,6 +187,7 @@ class IngestResult:
185
187
  def _verification_to_dict(
186
188
  verification: Optional[BalanceVerification],
187
189
  ) -> Optional[dict[str, Any]]:
190
+ """Serialize a :class:`BalanceVerification` to a JSON-safe dict."""
188
191
  if verification is None:
189
192
  return None
190
193
  return {
@@ -203,6 +206,7 @@ def _verification_to_dict(
203
206
  def _verification_from_dict(
204
207
  data: Any,
205
208
  ) -> Optional[BalanceVerification]:
209
+ """Reconstruct a :class:`BalanceVerification` from a decoded dict."""
206
210
  if data is None:
207
211
  return None
208
212
  if not isinstance(data, dict):
@@ -229,12 +233,14 @@ def _verification_from_dict(
229
233
 
230
234
 
231
235
  def _decimal_to_str(value: Optional[Decimal]) -> Optional[str]:
236
+ """Encode a ``Decimal`` as a plain string, preserving ``None``."""
232
237
  if value is None:
233
238
  return None
234
239
  return format(value, "f")
235
240
 
236
241
 
237
242
  def _decimal_from_str(value: Any) -> Optional[Decimal]:
243
+ """Decode a string back into a ``Decimal``, preserving ``None``/empty."""
238
244
  if value is None or value == "":
239
245
  return None
240
246
  return Decimal(str(value))
@@ -313,10 +319,20 @@ def smart_ingest(
313
319
 
314
320
 
315
321
  def _safe_detect(file_path: Path, warnings: list[str]) -> Optional[str]:
322
+ """Detect the statement format, recording a warning on no match."""
316
323
  try:
317
324
  return detect_statement_format(str(file_path))
318
325
  except Exception as exc:
319
- warnings.append(f"Format detection failed: {exc}")
326
+ logger.debug(
327
+ "Deterministic format detection did not match for %s: %s",
328
+ file_path,
329
+ exc,
330
+ )
331
+ warnings.append(
332
+ "No deterministic statement format matched "
333
+ "(not XML/CSV/OFX/QFX/MT940); routing to the hybrid "
334
+ "LLM/vision extraction pipeline"
335
+ )
320
336
  return None
321
337
 
322
338
 
@@ -328,6 +344,7 @@ def _run_deterministic(
328
344
  closing_balance: Optional[Decimal],
329
345
  warnings: list[str],
330
346
  ) -> IngestResult:
347
+ """Run the deterministic parser and wrap its output in an IngestResult."""
331
348
  parser = create_parser(str(file_path), fmt)
332
349
  raw = parser.parse()
333
350
  transactions = _coerce_transactions(raw, source=fmt)
@@ -443,6 +460,7 @@ def _run_vision(
443
460
  closing_balance: Optional[Decimal],
444
461
  warnings: list[str],
445
462
  ) -> IngestResult:
463
+ """Run the vision extractor and wrap its output in an IngestResult."""
446
464
  vision_extractor = vision_extractor or VisionExtractor()
447
465
  result = vision_extractor.extract(file_path)
448
466
  return _build_ingest_result(
@@ -462,6 +480,7 @@ def _build_ingest_result(
462
480
  closing_balance: Optional[Decimal],
463
481
  warnings: list[str],
464
482
  ) -> IngestResult:
483
+ """Assemble an IngestResult from an LLM result, running verification."""
465
484
  effective_opening = (
466
485
  opening_balance
467
486
  if opening_balance is not None
@@ -498,13 +517,13 @@ def _coerce_transactions(raw: object, *, source: str) -> list[Transaction]:
498
517
  if callable(to_dict):
499
518
  try:
500
519
  records = list(to_dict("records"))
501
- except TypeError: # pragma: no cover - non-DataFrame fallback
520
+ except TypeError:
502
521
  records = []
503
522
  elif isinstance(raw, list):
504
523
  records = [dict(item) for item in raw if isinstance(item, dict)]
505
524
  elif isinstance(raw, dict):
506
525
  records = [dict(raw)]
507
- else: # pragma: no cover - defensive
526
+ else:
508
527
  records = []
509
528
 
510
529
  transactions: list[Transaction] = []
@@ -90,17 +90,18 @@ def extract_text_pages(
90
90
 
91
91
  if engine == "pypdf":
92
92
  pages = _extract_with_pypdf(pdf_path)
93
- elif engine == "pdfplumber": # pragma: no cover - opt-in extra
93
+ elif engine == "pdfplumber":
94
94
  pages = _extract_with_pdfplumber(pdf_path)
95
- else: # pragma: no cover - guarded by Literal
95
+ else:
96
96
  raise PDFExtractionError(f"Unknown PDF engine: {engine}")
97
97
  return [_strip_noise(page) for page in pages]
98
98
 
99
99
 
100
100
  def _extract_with_pypdf(pdf_path: Path) -> list[str]:
101
+ """Extract per-page text using the ``pypdf`` engine."""
101
102
  try:
102
103
  from pypdf import PdfReader
103
- except ImportError as exc: # pragma: no cover - optional dep
104
+ except ImportError as exc:
104
105
  raise PDFExtractionError(
105
106
  "pypdf is required for PDF extraction. "
106
107
  "Install with: pip install bankstatementparser[hybrid]"
@@ -109,7 +110,7 @@ def _extract_with_pypdf(pdf_path: Path) -> list[str]:
109
110
  try:
110
111
  reader = PdfReader(str(pdf_path))
111
112
  return [page.extract_text() or "" for page in reader.pages]
112
- except Exception as exc: # pragma: no cover - defensive
113
+ except Exception as exc:
113
114
  raise PDFExtractionError(
114
115
  f"Failed to read PDF {pdf_path}: {exc}"
115
116
  ) from exc
@@ -117,7 +118,8 @@ def _extract_with_pypdf(pdf_path: Path) -> list[str]:
117
118
 
118
119
  def _extract_with_pdfplumber(
119
120
  pdf_path: Path,
120
- ) -> list[str]: # pragma: no cover - opt-in extra
121
+ ) -> list[str]:
122
+ """Extract per-page text using the ``pdfplumber`` engine."""
121
123
  try:
122
124
  import pdfplumber
123
125
  except ImportError as exc:
@@ -16,7 +16,7 @@
16
16
  """Golden Rule balance verification.
17
17
 
18
18
  If ``opening + credits - debits != closing`` the statement is flagged
19
- as ``Discrepancy`` (or ``Failed`` when balances are missing). This is
19
+ as ``Discrepancy`` (or ``Unverifiable`` when balances are missing). This is
20
20
  the single most important integrity check in the hybrid pipeline.
21
21
  """
22
22
 
@@ -34,10 +34,25 @@ from ..transaction_models import Transaction
34
34
 
35
35
 
36
36
  class VerificationStatus(str, Enum):
37
- """Verification outcome for a parsed statement."""
37
+ """Verification outcome for a parsed statement.
38
+
39
+ Members:
40
+ VERIFIED: The rule was applied and the books balance.
41
+ DISCREPANCY: The rule was applied and the books do **not**
42
+ balance.
43
+ UNVERIFIABLE: The rule could **not** be applied — e.g. a
44
+ missing opening/closing balance or fewer than two
45
+ statements for a continuity check. The statement is not
46
+ wrong; it simply cannot be checked, so it is routed to
47
+ human review.
48
+ FAILED: Reserved for a genuine verification error — the check
49
+ itself could not run. No current code path emits this; it
50
+ remains for forward compatibility and (de)serialization.
51
+ """
38
52
 
39
53
  VERIFIED = "verified"
40
54
  DISCREPANCY = "discrepancy"
55
+ UNVERIFIABLE = "unverifiable"
41
56
  FAILED = "failed"
42
57
 
43
58
 
@@ -76,7 +91,7 @@ def verify_balance(
76
91
 
77
92
  Returns:
78
93
  A :class:`BalanceVerification` describing the outcome. The
79
- ``status`` is ``FAILED`` only when the rule cannot be applied
94
+ ``status`` is ``UNVERIFIABLE`` when the rule cannot be applied
80
95
  (missing balances), not when it disagrees.
81
96
  """
82
97
  credits = Decimal("0")
@@ -91,7 +106,7 @@ def verify_balance(
91
106
 
92
107
  if opening_balance is None or closing_balance is None:
93
108
  return BalanceVerification(
94
- status=VerificationStatus.FAILED,
109
+ status=VerificationStatus.UNVERIFIABLE,
95
110
  opening_balance=opening_balance,
96
111
  closing_balance=closing_balance,
97
112
  total_credits=credits,
@@ -152,10 +167,10 @@ def verify_balance_multi_currency(
152
167
  Args:
153
168
  transactions: Iterable of normalized transactions.
154
169
  balances: Optional ``{currency: (opening, closing)}`` dict.
155
- When ``None``, verification runs with ``FAILED`` status
156
- for every currency (no balances provided). When a
170
+ When ``None``, verification runs with ``UNVERIFIABLE``
171
+ status for every currency (no balances provided). When a
157
172
  currency appears in the transactions but not in this
158
- dict, its verification is also ``FAILED``.
173
+ dict, its verification is also ``UNVERIFIABLE``.
159
174
  tolerance: Per-currency tolerance. Defaults to one cent.
160
175
 
161
176
  Returns:
@@ -188,9 +203,11 @@ def aggregate_verifications(
188
203
  ) -> BalanceVerification:
189
204
  """Collapse per-currency results into one statement-level verdict.
190
205
 
191
- The aggregate ``status`` is the worst per-currency outcome:
192
- ``DISCREPANCY`` if any currency disagrees, else ``FAILED`` if any
193
- currency could not be checked, else ``VERIFIED``.
206
+ The aggregate ``status`` is the worst per-currency outcome
207
+ (worst-first): ``DISCREPANCY`` if any currency disagrees, else
208
+ ``FAILED`` if any currency hit a genuine verification error, else
209
+ ``UNVERIFIABLE`` if any currency could not be checked, else
210
+ ``VERIFIED``.
194
211
 
195
212
  ``total_credits``/``total_debits``/``actual_delta`` are raw sums
196
213
  across all currencies — useful as row-count-style magnitudes, not
@@ -218,6 +235,8 @@ def aggregate_verifications(
218
235
  status = VerificationStatus.DISCREPANCY
219
236
  elif VerificationStatus.FAILED in statuses:
220
237
  status = VerificationStatus.FAILED
238
+ elif VerificationStatus.UNVERIFIABLE in statuses:
239
+ status = VerificationStatus.UNVERIFIABLE
221
240
  else:
222
241
  status = VerificationStatus.VERIFIED
223
242
 
@@ -286,13 +305,13 @@ def verify_continuity(
286
305
 
287
306
  Returns:
288
307
  A :class:`ContinuityResult`. ``status`` is ``DISCREPANCY``
289
- if any link has a gap beyond tolerance, else ``FAILED`` if
290
- any link could not be checked (missing balance on either
308
+ if any link has a gap beyond tolerance, else ``UNVERIFIABLE``
309
+ if any link could not be checked (missing balance on either
291
310
  side, or fewer than two statements), else ``VERIFIED``.
292
311
  """
293
312
  if len(statements) < 2:
294
313
  return ContinuityResult(
295
- status=VerificationStatus.FAILED,
314
+ status=VerificationStatus.UNVERIFIABLE,
296
315
  breaks=(),
297
316
  checked_links=0,
298
317
  unchecked_links=0,
@@ -337,7 +356,7 @@ def verify_continuity(
337
356
  )
338
357
  if unchecked:
339
358
  return ContinuityResult(
340
- status=VerificationStatus.FAILED,
359
+ status=VerificationStatus.UNVERIFIABLE,
341
360
  breaks=(),
342
361
  checked_links=checked,
343
362
  unchecked_links=unchecked,
@@ -374,8 +393,8 @@ def verify_transactions(
374
393
 
375
394
  The single ``opening_balance``/``closing_balance`` pair cannot
376
395
  be attributed to one currency of a multi-currency statement, so
377
- in that case every currency reports ``FAILED`` (cannot verify)
378
- rather than a spurious mismatch.
396
+ in that case every currency reports ``UNVERIFIABLE`` (cannot
397
+ verify) rather than a spurious mismatch.
379
398
 
380
399
  Args:
381
400
  transactions: Iterable of normalized transactions.
@@ -314,9 +314,10 @@ class VisionExtractor:
314
314
  )
315
315
 
316
316
  def _render_pages(self, pdf_path: Path) -> list[bytes]:
317
+ """Render each PDF page (up to :attr:`max_pages`) as a PNG."""
317
318
  try:
318
319
  import pypdfium2 as pdfium
319
- except ImportError as exc: # pragma: no cover - optional dep
320
+ except ImportError as exc:
320
321
  raise VisionExtractorError(
321
322
  "pypdfium2 is required for vision extraction. "
322
323
  "Install with: "
@@ -358,7 +359,7 @@ class VisionExtractor:
358
359
  """
359
360
  try:
360
361
  import pypdfium2 as pdfium
361
- except ImportError as exc: # pragma: no cover - optional dep
362
+ except ImportError as exc:
362
363
  raise VisionExtractorError(
363
364
  "pypdfium2 is required for vision extraction. "
364
365
  "Install with: "
@@ -407,6 +408,7 @@ class VisionExtractor:
407
408
  return all_strips
408
409
 
409
410
  def _resolve_completion(self) -> CompletionFn:
411
+ """Return the injected completion callable or import a backend."""
410
412
  if self._completion_fn is not None:
411
413
  return self._completion_fn
412
414
  # Auto-select the direct Ollama bridge for any ollama/* model
@@ -421,15 +423,15 @@ class VisionExtractor:
421
423
 
422
424
  if is_ollama_model(self.model):
423
425
  return ollama_direct_completion
424
- try: # pragma: no cover - optional dep
426
+ try:
425
427
  from litellm import completion
426
- except ImportError as exc: # pragma: no cover - optional dep
428
+ except ImportError as exc:
427
429
  raise VisionExtractorError(
428
430
  "litellm is required for vision extraction. "
429
431
  "Install with: "
430
432
  "pip install bankstatementparser[hybrid-vision]"
431
433
  ) from exc
432
- return completion # type: ignore[no-any-return] # pragma: no cover
434
+ return completion # type: ignore[no-any-return]
433
435
 
434
436
 
435
437
  STRIP_HEADER_SYSTEM_PROMPT = """You are a meticulous Financial Data
@@ -110,7 +110,7 @@ def parse_files_parallel(
110
110
  path = future_to_path[future]
111
111
  try:
112
112
  results[path] = future.result()
113
- except Exception as exc: # pragma: no cover
113
+ except Exception as exc:
114
114
  results[path] = FileResult(
115
115
  path=path,
116
116
  status="FAILED",
@@ -31,12 +31,14 @@ from .transaction_models import Transaction
31
31
 
32
32
 
33
33
  def _days_between(left: date | None, right: date | None) -> int | None:
34
+ """Return the absolute day gap between two dates, or None."""
34
35
  if left is None or right is None:
35
36
  return None
36
37
  return abs((left - right).days)
37
38
 
38
39
 
39
40
  def _description_similarity(left: Transaction, right: Transaction) -> float:
41
+ """Return the 0.0-1.0 similarity of two normalized descriptions."""
40
42
  if not left.normalized_description or not right.normalized_description:
41
43
  return 0.0
42
44
  return SequenceMatcher(
@@ -76,6 +78,8 @@ class DeduplicationResult(BaseModel):
76
78
 
77
79
  @dataclass(frozen=True)
78
80
  class _Candidate:
81
+ """A transaction paired with its source index and primary hash."""
82
+
79
83
  index: int
80
84
  transaction: Transaction
81
85
  primary_hash: str
@@ -247,6 +251,7 @@ class Deduplicator:
247
251
  def _candidate_groups_by_primary(
248
252
  self, candidates: list[_Candidate]
249
253
  ) -> dict[str, list[_Candidate]]:
254
+ """Group candidates by their primary hash."""
250
255
  groups: dict[str, list[_Candidate]] = defaultdict(list)
251
256
  for candidate in candidates:
252
257
  groups[candidate.primary_hash].append(candidate)
@@ -255,6 +260,7 @@ class Deduplicator:
255
260
  def _find_exact_duplicates(
256
261
  self, candidates: list[_Candidate]
257
262
  ) -> list[ExactDuplicateGroup]:
263
+ """Return groups of candidates sharing a primary hash."""
258
264
  groups = self._candidate_groups_by_primary(candidates)
259
265
  exact_groups = []
260
266
  for primary_hash, bucket in sorted(groups.items()):
@@ -281,6 +287,7 @@ class Deduplicator:
281
287
  *,
282
288
  excluded_indices: set[int],
283
289
  ) -> tuple[list[MatchGroup], set[int]]:
290
+ """Find probable and temporal match groups for operator review."""
284
291
  probable_groups, probable_indices = self._find_probable_matches(
285
292
  candidates
286
293
  )
@@ -299,6 +306,7 @@ class Deduplicator:
299
306
  def _find_probable_matches(
300
307
  self, candidates: list[_Candidate]
301
308
  ) -> tuple[list[MatchGroup], set[int]]:
309
+ """Match candidates by hash collision and description similarity."""
302
310
  groups = []
303
311
  matched_indices: set[int] = set()
304
312
  for bucket in self._candidate_groups_by_primary(candidates).values():
@@ -343,6 +351,7 @@ class Deduplicator:
343
351
  def _find_temporal_matches(
344
352
  self, candidates: list[_Candidate]
345
353
  ) -> tuple[list[MatchGroup], set[int]]:
354
+ """Match candidates with equal amounts but shifted value dates."""
346
355
  buckets: dict[tuple[str, str, str], list[_Candidate]] = defaultdict(
347
356
  list
348
357
  )
@@ -363,6 +372,7 @@ class Deduplicator:
363
372
  component: list[_Candidate],
364
373
  similarities: list[float],
365
374
  ) -> None:
375
+ """Record a matched component as a temporal match group."""
366
376
  matched_indices.update(candidate.index for candidate in component)
367
377
  groups.append(self._temporal_group(component, similarities))
368
378
 
@@ -413,6 +423,7 @@ class Deduplicator:
413
423
  component: list[_Candidate],
414
424
  similarities: list[float],
415
425
  ) -> MatchGroup:
426
+ """Build a temporal MatchGroup with a confidence and reason."""
416
427
  max_delta = (
417
428
  _days_between(
418
429
  component[0].transaction.value_date,
@@ -34,6 +34,7 @@ from pydantic import (
34
34
 
35
35
 
36
36
  def _coerce_decimal(value: object) -> Decimal:
37
+ """Coerce a value into a Decimal amount, raising if empty."""
37
38
  text = str(value).strip()
38
39
  if not text:
39
40
  raise ValueError("amount is required")
@@ -41,6 +42,7 @@ def _coerce_decimal(value: object) -> Decimal:
41
42
 
42
43
 
43
44
  def _parse_date(value: object) -> date | None:
45
+ """Parse a value into a date across common formats, or None."""
44
46
  if value in (None, ""):
45
47
  return None
46
48
  if isinstance(value, date) and not isinstance(value, datetime):
@@ -98,6 +100,7 @@ def normalize_description(value: str | None) -> str:
98
100
 
99
101
 
100
102
  def _first_value(record: Mapping[str, object], *keys: str) -> object | None:
103
+ """Return the first non-empty value among the given keys, or None."""
101
104
  for key in keys:
102
105
  if key in record and record[key] not in (None, ""):
103
106
  return record[key]
@@ -136,6 +139,7 @@ class BoundingBox(BaseModel):
136
139
 
137
140
  @model_validator(mode="after")
138
141
  def _check_non_inverted(self) -> BoundingBox:
142
+ """Validate that the box corners are not inverted."""
139
143
  if self.x0 > self.x1:
140
144
  raise ValueError(
141
145
  f"BoundingBox x0 ({self.x0}) must be <= x1 ({self.x1})"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "bankstatementparser"
3
- version = "0.0.9"
3
+ version = "0.0.10"
4
4
  description = "BankStatementParser is your essential tool for easy bank statement management. Designed with finance and treasury experts in mind, it offers a simple way to handle CAMT (ISO 20022) formats and more. Get quick, accurate insights from your financial data and spend less time on processing. It's the smart, hassle-free way to stay on top of your transactions."
5
5
  authors = ["Sebastien Rousseau <sebastian.rousseau@gmail.com>"]
6
6
  license = "Apache Software License"
@@ -31,9 +31,10 @@ pytest = ">=8.0.0,<10.0.0"
31
31
  pytest-cov = "^7.1.0"
32
32
  pytest-benchmark = "^5.1.0"
33
33
  ruff = "^0.15.7"
34
- mypy = "^1.5.0"
34
+ mypy = ">=1.5,<3"
35
35
  bandit = "^1.7.0"
36
36
  hypothesis = ">=6.82,<7"
37
+ interrogate = "^1.7.0"
37
38
 
38
39
  [tool.poetry.group.docs]
39
40
  optional = true
@@ -162,6 +163,28 @@ markers = [
162
163
  "slow: marks tests as slow-running performance or regression checks",
163
164
  ]
164
165
 
166
+ [tool.interrogate]
167
+ # Documentation coverage is enforced at 100% across the package, the
168
+ # developer/CI helper scripts, and the runnable examples. Test modules
169
+ # and the local virtualenv are excluded; everything else — including
170
+ # private and nested helpers — must carry a docstring.
171
+ fail-under = 100
172
+ ignore-init-method = true
173
+ ignore-magic = true
174
+ ignore-module = false
175
+ ignore-private = false
176
+ ignore-nested-functions = false
177
+ ignore-semiprivate = false
178
+ exclude = [
179
+ ".venv",
180
+ "build",
181
+ "dist",
182
+ "tests",
183
+ "setup.py",
184
+ "docs",
185
+ ]
186
+ verbose = 1
187
+
165
188
  [tool.coverage.run]
166
189
  source = ["bankstatementparser"]
167
190
  branch = true
@@ -170,5 +193,4 @@ branch = true
170
193
  exclude_lines = [
171
194
  "pragma: no cover",
172
195
  "if TYPE_CHECKING:",
173
- "raise NotImplementedError",
174
196
  ]