Skip to content

Full audit: fix every confirmed issue#127

Open
wolph wants to merge 18 commits into
developfrom
feature/full-audit-fixes
Open

Full audit: fix every confirmed issue#127
wolph wants to merge 18 commits into
developfrom
feature/full-audit-fixes

Conversation

@wolph

@wolph wolph commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Full audit of mt940 (spec: 2026-07-06)

Full-codebase audit: baseline type-check hygiene, every open/recently-closed issue re-triaged against current develop, and a spec-driven latent-bug hunt across tags.py, models.py, processors.py/json.py, and parser.py/utils.py. Full log: docs/superpowers/plans/2026-07-06-audit-findings.md.

Fixed

  • Uninitialized instance attrs + import-cycle config flagged by basedpyright — added ClassVar annotations in tags.py and single-sourced the pyright config in pyproject.toml (2d37679, ab773c6)
  • :13:/:13D: timezone offset subfield was read as raw HHMM digits and passed straight to models.DateTime, whose offset kwarg is minutes (+0100 became UTC+1:40); a negative or missing offset crashed with TypeError. Convert HHMM to signed minutes in DateTimeIndication.__call__, drop the offset keys when absent (36b5e66)
  • :86: transaction details were silently truncated after nine 65-char chunks (~593 chars) — capture is now unbounded (5735f2d)
  • :NS: (non-SWIFT) multi-line free-form values aborted the entire parse whenever a line didn't start with \d{2} — pattern now accepts any content, __call__ line-keeping order fixed (427cf06)
  • Tag.parse documents RuntimeError but raised a raw re.error for any tag whose pattern spread a group over several lines, masking the real diagnostic — fixed in _debug_partial_match (f8704dd)
  • :34F: floor limit with a blank or lowercase D/C mark produced a bogus ' _floor_limit' key and skipped debit negation — mark is now stripped/uppercased before dispatch (243d387)
  • Amount only treated an exact-case 'D' as debit even though the tag patterns are case-insensitive — a lowercase d mark stored a debit as a positive amount (silent wrong sign). Comparison is now case-insensitive (ff3d24a)
  • A leading + inside German structured :86: (GVC) free text truncated purpose/details before the real key (:86:020?20AB+EREF'EREF' instead of 'AB+EREF') — index-guard fix in _parse_mt940_gvcodes (3c0dbfe)
  • Two structured :86: tags on one transaction crashed with TypeError: NoneType += str when merging sibling sub-fields (or silently clobbered a value with None, depending on order) — now appends only when both sides are strings, otherwise assigns (0ef2211)
  • A leading byte-order-mark (UTF-8/UTF-16 BOM) survived Transactions.strip (rstrip-only) and shifted the file's first :20: line off the ^: start-of-line anchor, dropping transaction_reference — BOM is now stripped in _read (9b2e094)

Investigated, not a bug

Issues re-triaged (30 most recently closed, plus #105):

Spec-driven latent-bug hunt (tags.py):

  • :61: amount-without-decimals, RD/RC mark parsing, and a second // in the bank reference all parse correctly
  • Balance 1!a6!n3!a15d: 29 Feb in a leap year parses; non-leap 29 Feb is correctly rejected by datetime
  • :28C: optional /2n sequence number parses correctly (sequence_number=None when absent); an over-long unslashed statement number splits across both fields — longstanding documented leniency, same override path as Non-numeric statement number #92

models.py semantics:

  • Two-digit-year handling is a single uniform +2000 rule (no 49/50-style sliding pivot), documented and total for MT940's 2-digit YYMMDD
  • :61: entry-date year-rollover inference (forward/backward/same-year) is correct on all branches
  • Balance.__eq__ excludes date while __str__/__repr__ include it — a defensible API-design choice, not a correctness defect
  • Transaction.data inheritance from Transactions.data is only "ordering sensitive" for malformed input where a statement tag arrives after its transactions, which is not plausible bank output
  • Transactions Sequence contract (__getitem__/__len__/iteration/reversed) and pickle round-trip (__getstate__/__setstate__, including transaction_boundary) both work correctly; coverage added

processors.py / json.py:

  • mt940.JSONEncoder round-trips every model type that reaches .data (Decimal, Date, DateTime+FixedOffset, Balance, Amount, SumAmount) across all fixtures; coverage added
  • A literal ? or duplicated sub-field code inside a structured :86: value is malformed input (? is the format's own delimiter) — deliberate leniency, not a bug
  • date_fixup_pre_processor's Feb-only clamp cannot over-fire on other months and is leap-consistent across the full 2-digit year range; coverage added for the non-leap 29→28 path
  • utils.coalesce correctly treats falsy-but-valid values ('', 0, False) as present via an explicit is not None check

parser.py / utils.py:

  • parse_statements()'s ^:20: block splitting correctly drops a SWIFT {1:}{2:}{4: header, absorbs - terminators, and never produces an empty trailing block; coverage added
  • A :86: continuation line starting with an unknown tag-lookalike (e.g. :12:) correctly stays inside the :86: slice with no structural corruption; coverage added
  • Transactions.strip never lstrips, so significant leading whitespace on :86: continuations is preserved; coverage added
  • Decoding a raw latin-1 byte without an explicit encoding= follows the documented, deliberate fallback-chain order (cp852 before latin1); a raw no-newline MT940 string is not plausible as a real file path, so the path-vs-data dispatch ambiguity is inherent, not a bug

Needs decision

Three items, each pinned by a strict (xfail(strict=True)) regression test so a future accidental fix is caught rather than silently changing behavior:

  • Issue First intermediate_opening_balance line missing #105, shape 2 (one :20: block, multiple :28C: pages — PAGED_SINGLE_BLOCK): parse_statements() splits only on ^:20:, so a paged-single-block file becomes one Transactions, and self.data.update(result) (models.py:600) lets the second page's :60M:/:25: overwrite the first page's values. Two fix shapes, each with a real tradeoff: B1 — also split parse_statements() on :28C: page boundaries (matches the existing one-Transactions-per-statement model, no models.py change needed, but multiplies the returned list beyond one-per-:20: and needs a page-boundary heuristic for banks using a different paging tag). B2 — flip models.py:600 to first-wins (no new splitting heuristic, but :62M:/:62F:/:64:/:65: closing/available/forward balances share the exact same unscoped code path as the opening-type tags, so a blanket first-wins would make the FIRST page's closing balance win instead of the true final one — a real regression for genuine multi-page statements; only safe if scoped per-tag, which is a judgment call). Strict xfail: test_paged_single_block_first_intermediate_balance_accessible (mt940_tests/test_issues/test_issue105_first_balance.py).
  • :61: RC (reversal of credit) amount sign: RC leaves the amount positive (RD correctly stays positive too); a credit reversal arguably removes funds and should negate, but real RC transactions in mt940_tests/betterplace/sepa_mt9401.sta (lines 19/101) have goldens with positive amounts, so flipping the sign is a compatibility break for any consumer keying off status='RC' + positive amount. Strict xfail: test_statement_rc_reversal_amount_is_negative (mt940_tests/test_tags.py).
  • Repeated structured :86: None-clobber semantics: the crash on two structured :86: tags per transaction is fixed (0ef2211), but an incoming None sub-field still overwrites an earlier real value (last :86: wins per key). "Preserve non-None" would be more correct but can't distinguish a value set by a prior :86: from one set by :61:, so applying it changes customer_reference from None back to :61:'s value on 5 real-bank goldens: betterplace/sepa_mt9401.sta, betterplace/sepa_snippet.sta, cmxl/statement_details.sta, self-provided/sparkassen.sta, self-provided/wrapped_timestamp.sta. Strict xfail: test_repeated_structured_86_preserves_real_values (mt940_tests/test_processors.py).

Release notes

User-visible behavior changes worth calling out in the changelog. Recommend releasing this as 5.1.0 rather than 5.0.1 — these correct previously-wrong output, not pure patches. No version bump included in this PR; release is out of scope here.

  • :13:/:13D: timezone offsets were misparsed as minutes instead of signed HHMM (off by up to ~7x) and crashed on negative/missing offsets; also changes FixedOffset.tzname()'s string form from raw HHMM digits (e.g. '0100') to signed minutes (e.g. '60')
  • :86: transaction details longer than ~593 characters are no longer truncated
  • :NS: (non-SWIFT) multi-line free-form values that previously aborted the entire parse now parse successfully
  • Tag.parse now raises the documented RuntimeError instead of a raw re.error for certain unmatched multi-line patterns
  • :34F: floor limit with a blank or lowercase D/C mark now produces the correct key and sign instead of a bogus ' _floor_limit' key
  • Lowercase d debit marks (already accepted leniently by the case-insensitive tag patterns) are now correctly negated instead of silently stored as a positive amount
  • A leading + in German structured :86: (GVC) free text no longer truncates the purpose/details text before the real key
  • Two structured :86: tags on the same transaction no longer crash with TypeError
  • A leading byte-order-mark (BOM) no longer drops the file's first :20: tag data

Verification

  • Full tox matrix (py3.10–3.13, lint, mypy, pyright, pyrefly, docs -W, codespell, repo-review) green at fef884f, coverage 100% (637 stmts / 176 branches)
  • basedpyright: 0 errors, 257 warnings (baseline before this audit: 0 errors, 255 warnings)
  • uv run pytest -q: 193 passed, 3 xfailed (strict) — the three needs-decision items above

wolph added 18 commits July 6, 2026 17:53
…dpyright

- Add ClassVar annotations to class attributes in tags.py (pattern, name, slug, logger, status, RE_FLAGS, scope)
- Fix multiple inheritance type compatibility in models.py with proper type: ignore comments
- Add pyrightconfig.json to disable reportImportCycles (which pyright already ignores)

These changes ensure basedpyright shows 0 errors, matching the strict pyright configuration.
- Delete pyrightconfig.json: its presence made pyright ignore [tool.pyright]
  in pyproject.toml entirely, creating two sources of truth and an IDE-level
  regression ('mt940 is not defined' in parser.py).
- Move reportImportCycles = false into the existing [tool.pyright] block with
  a comment: mt940/parser.py imports the package root by design. basedpyright
  reads [tool.pyright] too.
- Parameterize Tag.RE_FLAGS as ClassVar[re.RegexFlag] instead of bare ClassVar.
The :13:/:13D: offset subfield is a signed HHMM value (+0130 = 1h30m) but
was passed raw to models.DateTime, which expects minutes (+0100 became
UTC+1:40). A negative sign was never matched and a missing offset passed
offset=None, both crashing DateTime with TypeError. Capture the sign,
convert HHMM to signed minutes in DateTimeIndication.__call__, and drop
the offset keys entirely when absent. Regenerate the mBank mt942 golden
(stored instant was wrong by 40 minutes).
…hunks

The TransactionDetails pattern capped the capture at ~593 characters
(eight 65-char chunks plus one), silently dropping the rest of longer
multi-line details. Real banks exceed the 6x65 SWIFT cap routinely -- the
cap was already raised once in 4575222 for exactly that reason, and the
multiline.sta fixture was still losing its ?61-?65 subfields (visible in
the regenerated golden: additional_purpose now carries the full text).
Capture is now unbounded; Transactions.parse already limits the value to
the text between this tag and the next one.
…tent

The NonSwift pattern only matched values that were either a single line or
consisted entirely of two-digit-prefixed lines; any other multi-line value
(NS is explicitly bank specific free-form data) failed to match and
aborted the whole parse. On top of that, the failure surfaced as
re.PatternError because the debug helper re-compiles unbalanced pattern
fragments. Accept any content in the pattern and keep per-line 2!n35x
extraction in __call__. Also reorder the __call__ branches so free-form
lines keep their content in non_swift_text instead of being replaced by an
empty line (the raphaelm golden: bare "32" sub-tag line is now kept,
consistent with the "33" line right after it).
The pattern accepts '[DC ]?' for the D/C mark (the space was added in
d36c51b for Fiducia/Volksbank ':34F:EUR 999999999999,99' input), but
__call__ treated the space as a real status and produced a bogus
' _floor_limit' key instead of the d_/c_floor_limit pair an absent mark
yields. A lowercase mark (matched via re.IGNORECASE) produced the right
key but skipped Amount's debit negation ('d' != 'D'). Strip and uppercase
the mark before dispatching.
…values

Tag.parse documents RuntimeError for unparseable values, but before
raising it calls _debug_partial_match, which re-compiles the pattern one
line at a time. For any pattern with a group spread over several lines the
fragments are unbalanced, so re.match raised re.error (re.PatternError)
and masked both the RuntimeError and the diagnostics it was supposed to
produce. Skip uncompilable fragments. The failure branch and the helper
are now exercised by a test, so their no-cover pragmas are removed.
Strict xfail documenting that a reversal-of-credit (RC) mark leaves the
amount positive although funds leave the account (needs a user decision:
flipping the sign would change the betterplace fixture goldens and any
downstream consumer relying on status+positive amount).

Passing edge coverage: RD/RC mark parsing, 15d amount without fraction
digits (10,), a second // kept inside the bank reference, and a balance
on 29 February in a leap year.
The tag patterns are compiled with re.IGNORECASE, so a bank sending a
lowercase 'd' debit mark in :61: or a balance is accepted by the regex,
but Amount only negated on an exact 'D' -- a debit was silently stored
as a positive amount. Compare the mark case-insensitively (status.upper()
== 'D'). Reversal marks RD/RC are unchanged (still positive).
The existing pickle test only exercises default (empty) state. Guard the
newer opt-in transaction_boundary attribute (issue #110) against a future
__getstate__/__setstate__ regression that could drop it.
The existing test_json_dump only calls json.dumps over .sta fixtures with no
value assertions; add full dumps->loads round-trips asserting the string forms
of nested Balance/Amount/Date, the SumAmount entry number, the :13D: DateTime
FixedOffset, and the opt-in StatementASNB .txt path. Also cover the previously
unexercised non-leap-year February clamp in date_fixup_pre_processor.
_parse_mt940_gvcodes sliced purpose[index - 4:index] with no lower bound,
so a '+' before index 4 produced a wrapped/empty slice matching the
empty-string GVC key. A literal '+' in the leading free text of a
structured :86: (e.g. 'AB+EREF', 'A+B SVWZ TEXT') was thus treated as a
KEYWORD+ separator, truncating the purpose. GVC keywords are four
characters wide, so guard the match on index >= 4. The previously
unreachable else branch is now covered; drop its pragmas.
A structured :86: emits every DETAIL_KEYS value including None for absent
sub-fields. A second structured :86: on the same :61: then hit
'None += str' in _update_transaction (TypeError aborting the whole file)
in either tag order. Append now requires BOTH sides to be strings; any
other combination assigns the incoming value, so a later string replaces
an existing None. Current clobber semantics are unchanged (an incoming
None still overwrites -- the behavior the real-bank goldens encode);
the preservation question is pinned as a strict xfail pending decision.
A UTF-8/UTF-16 byte-order mark decodes to U+FEFF, which is not whitespace
and so survives Transactions.strip. It pushed the first :20: off the
start-of-line tag anchor, so transaction_reference was silently lost on
BOM-prefixed files (common from Windows tools/banks). Strip a leading BOM
in _read for both bytes and str input.
…dent

Regression guards for previously-untested parser edges verified correct
during the audit: a :86: line starting with an unknown tag-lookalike (:12:)
stays in details; parse_statements drops a leading SWIFT header and absorbs
lone `-` terminators; :86: continuation-line leading whitespace is kept.
Copilot AI review requested due to automatic review settings July 7, 2026 08:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread mt940/models.py
Comment on lines +739 to +741
class TransactionsAndTransaction( # type: ignore[misc] # pyright: ignore[reportUnsafeMultipleInheritance]
Transactions, Transaction
):
Comment thread mt940/tags.py
logger: logging.Logger
RE_FLAGS: ClassVar[re.RegexFlag] = re.IGNORECASE | re.VERBOSE | re.UNICODE
scope: ClassVar[type[models.Transactions | models.Transaction]] = (
models.Transactions

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several robustness improvements and bug fixes to the MT940 parser, including case-insensitive handling of debit/credit status marks, stripping of leading byte-order marks (BOM), safe GVC code parsing with index guards, relaxed length limits for transaction details and non-SWIFT tags, and timezone offset conversions for date-time indications. Feedback on the changes suggests preventing silent data loss during transaction updates by not clobbering existing values with None from subsequent structured tags, and safely handling potential None values for the status field in the floor limit indicator to avoid AttributeError.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread mt940/models.py
Comment on lines 641 to 642
else:
transaction.data[k] = v

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Overwriting existing non-None values with None from subsequent structured :86: tags causes silent data loss (e.g., clobbering the customer_reference set by :61:). We should only update the transaction data if the incoming value v is not None, or if the key does not exist in the transaction data yet. This resolves the clobbering issue and allows the pending test_repeated_structured_86_preserves_real_values test to pass successfully.

Suggested change
else:
transaction.data[k] = v
elif v is not None or k not in transaction.data:
transaction.data[k] = v

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is the more intuitive semantics, but it's deliberately quarantined rather than fixed in this PR: the suggested rule (elif v is not None or k not in transaction.data) changes parse output on 5 real-bank fixtures (betterplace sepa_mt9401/sepa_snippet, cmxl statement_details, sparkassen, wrapped_timestamp), where a structured :86: currently nulls the customer_reference set by :61: (e.g. NONREF) and the goldens pin customer_reference: null. Whether that null-out is a bug or intended ('structured details are authoritative') is a maintainer decision — it's tracked by the strict xfail test_repeated_structured_86_preserves_real_values and the PR description's Needs decision section. This PR ships only the non-debatable part: the TypeError crash fix.

Comment thread mt940/tags.py
# d36c51b) means "both", like an absent mark, and a lowercase mark
# (the patterns match case-insensitively) must behave like its
# uppercase form so the debit amount is negated consistently.
status: str = data['status'].strip().upper()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To enforce defensive programming and prevent potential AttributeError if status is ever None (e.g., if the group is somehow missing or modified in custom subclasses), we should safely handle potential None values before calling .strip().

Suggested change
status: str = data['status'].strip().upper()
status: str = (data.get('status') or '').strip().upper()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The status group can't be None here: the shipped pattern uses (?P<status>[DC ]?), which always participates in the match (empty string when the mark is absent), so data['status'] is always a str. A None would require a custom Tag subclass whose pattern drops the group — and in that case a loud AttributeError at the call site is preferable to silently coercing to '' (errors shouldn't pass silently). Leaving as-is per the audit's no-speculative-guards rule.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants