Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2d37679
fix: uninitialized instance variables and import cycles found by base…
wolph Jul 6, 2026
ab773c6
fix: single-source pyright config in pyproject.toml, typed RE_FLAGS
wolph Jul 6, 2026
26b2eb3
test: reproduce issue #105 paged-block first balance loss (refs #105)
wolph Jul 6, 2026
36b5e66
fix: :13: offset misparsed as minutes; sign and missing offset crashed
wolph Jul 6, 2026
5735f2d
fix: :86: transaction details silently truncated after nine 65-char c…
wolph Jul 6, 2026
427cf06
fix: :NS: multi-line free text aborted the parse and dropped line con…
wolph Jul 6, 2026
243d387
fix: :34F: blank or lowercase D/C mark produced wrong key and sign
wolph Jul 6, 2026
f8704dd
fix: Tag.parse raised re.error instead of RuntimeError for unmatched …
wolph Jul 6, 2026
18a6d95
test: xfail :61: RC reversal amount sign; cover spec edge cases
wolph Jul 6, 2026
ff3d24a
fix: Amount treated only uppercase D as debit, dropping lowercase-d sign
wolph Jul 7, 2026
bcbcc9f
test: cover pickle round-trip preserving transaction_boundary
wolph Jul 7, 2026
40fb4c6
test: cover JSONEncoder round-trip value forms and non-leap Feb clamp
wolph Jul 7, 2026
3c0dbfe
fix: leading + in :86: GVC free text truncated purpose
wolph Jul 7, 2026
0ef2211
fix: merging repeated structured :86: tags crashed on None details
wolph Jul 7, 2026
9b2e094
fix: leading BOM dropped the first :20: tag's data
wolph Jul 7, 2026
7631073
test: cover embedded tag-lookalike, parse_statements headers, :86: in…
wolph Jul 7, 2026
a35284e
fix: line length, quote style and spelling found by lint and codespell
wolph Jul 7, 2026
fef884f
fix: replace invisible U+FEFF literal with visible escape in parser
wolph Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions mt940/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,15 +214,20 @@
"""Coerce ``amount`` to a signed :class:`decimal.Decimal`.

``status`` is ``'C'`` for credit (positive) or ``'D'`` for debit, in
which case the amount is negated. Extra keyword arguments are ignored
so a parsed tag dictionary can be splatted in directly.
which case the amount is negated. The comparison is case-insensitive:
the tag patterns are compiled with :data:`re.IGNORECASE`, so a bank
that sends a lowercase ``'d'`` mark must still have its debit negated
rather than silently stored as a positive amount. Reversal marks
(``'RD'``/``'RC'``) are intentionally left unchanged here. Extra
keyword arguments are ignored so a parsed tag dictionary can be
splatted in directly.
"""
self.amount = decimal.Decimal(amount.replace(',', '.'))
self.currency = currency

# C = credit, D = debit

if status == 'D':
if status.upper() == 'D':
self.amount = -self.amount

def __eq__(self, other: object) -> bool:
Expand Down Expand Up @@ -619,12 +624,19 @@
def _update_transaction(self, result: dict[str, Any]) -> None:
"""Merge a transaction-scoped result into the current transaction.

New keys are set directly; string values for keys that already exist
are appended on a new line (e.g. multi-line ``:86:`` details).
New keys are set directly; when both the existing and incoming
values are strings, the incoming one is appended on a new line
(e.g. multi-line ``:86:`` details). Any other combination assigns
the incoming value: a structured ``:86:`` emits ``None`` for absent
sub-fields, so a later string must replace an existing ``None``
rather than crash on ``None += str`` (and an incoming ``None``
still overwrites, preserving the merge semantics the fixture
goldens encode).
"""
transaction = self.transactions[-1]
for k, v in result.items():
if k in transaction.data and hasattr(v, 'strip'):
existing = transaction.data.get(k)
if hasattr(existing, 'strip') and hasattr(v, 'strip'):
transaction.data[k] += f'\n{v.strip()}'
else:
transaction.data[k] = v
Comment on lines 641 to 642

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.

Expand Down Expand Up @@ -724,7 +736,9 @@
return valid_matches


class TransactionsAndTransaction(Transactions, Transaction): # type: ignore[misc]
class TransactionsAndTransaction( # type: ignore[misc] # pyright: ignore[reportUnsafeMultipleInheritance]
Transactions, Transaction
):

Check failure

Code scanning / CodeQL

Missing call to superclass `__init__` during object initialization Error

This class does not call
Transaction.__init__
during initialization. (The class lacks an __init__ method to ensure every base class __init__ is called.)
Comment on lines +739 to +741
"""
Subclass of both Transactions and Transaction for scope definitions.

Expand Down
7 changes: 6 additions & 1 deletion mt940/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ def safe_is_file(filename: Any) -> bool:
raise exception # pragma: no cover

assert isinstance(data, str)
return data
# Strip a leading byte-order mark (U+FEFF). A BOM survives decoding as
# U+FEFF for UTF-8 input (and for UTF-16 only when an explicit
# utf-16-le/-be encoding is passed). It is not whitespace, so it would
# otherwise displace the first `:20:` off the start-of-line tag anchor and
# silently drop that tag's data.
return data.removeprefix('\ufeff')


def parse(
Expand Down
17 changes: 13 additions & 4 deletions mt940/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,17 @@ def _parse_mt940_gvcodes(purpose: str) -> dict[str, str | None]:

for index, char in enumerate(purpose):
# Detect the beginning of a GVC segment: if a '+' is encountered
# and the four characters preceding it form a valid GVC key.
if char == '+' and purpose[index - 4 : index] in GVC_KEYS:
# and the four characters preceding it form a valid GVC key. GVC
# keywords are four characters wide, so a '+' before index 4 cannot
# terminate one; guarding on ``index >= 4`` also avoids a negative
# ``purpose[index - 4:index]`` slice wrapping to the empty string
# (which spuriously matched the empty-string GVC key and truncated a
# literal '+' in the leading free text).
if (
char == '+'
and index >= 4
and purpose[index - 4 : index] in GVC_KEYS
):
if segment_type:
# If already processing a segment, finalize it by removing
# the trailing GVC key and reset the text accumulator.
Expand All @@ -372,10 +381,10 @@ def _parse_mt940_gvcodes(purpose: str) -> dict[str, str | None]:
else:
text += char

if segment_type: # pragma: no branch
if segment_type:
tmp[segment_type] = text
else:
tmp[''] = text # pragma: no cover
tmp[''] = text

for key, value in tmp.items():
result[GVC_KEYS[key]] = value
Expand Down
82 changes: 57 additions & 25 deletions mt940/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
import logging
import re
import typing
from typing import ClassVar

from . import models

Expand All @@ -90,12 +91,14 @@
"""

id: str | int = 0
RE_FLAGS = re.IGNORECASE | re.VERBOSE | re.UNICODE
scope: type[models.Transactions | models.Transaction] = models.Transactions
pattern: str
name: str
slug: str
logger: logging.Logger
RE_FLAGS: ClassVar[re.RegexFlag] = re.IGNORECASE | re.VERBOSE | re.UNICODE
scope: ClassVar[type[models.Transactions | models.Transaction]] = (
models.Transactions

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'Transactions' may not be defined if module
mt940.models
is imported before module
mt940.tags
, as the
definition
of Transactions occurs after the cyclic
import
of mt940.tags.
)
pattern: ClassVar[str]
name: ClassVar[str]
slug: ClassVar[str]
logger: ClassVar[logging.Logger]

def __init__(self) -> None:
self.re = re.compile(self.pattern, self.RE_FLAGS)
Expand All @@ -117,7 +120,7 @@
RuntimeError: If the value does not match the tag's pattern.
"""
match = self.re.match(value)
if match: # pragma: no branch
if match:
self.logger.debug(
'matched (%d) %r against "%s", got: %s',
len(value),
Expand All @@ -126,7 +129,7 @@
match.groupdict(),
)
return match.groupdict()
else: # pragma: no cover
else:
self.logger.error(
'matching id=%s (len=%d) "%s" against\n %s',
self.id,
Expand All @@ -139,13 +142,20 @@
f'Unable to parse {self!r} from {value!r}', self, value
)

def _debug_partial_match(self, value: str) -> None: # pragma: no cover
def _debug_partial_match(self, value: str) -> None:
"""
Helper function to debug partial matches against the pattern.
"""
part_value = value
for pattern in self.pattern.split('\n'):
match = re.match(pattern, part_value, self.RE_FLAGS)
try:
match = re.match(pattern, part_value, self.RE_FLAGS)
except re.error:
# Single lines of a pattern with multi-line groups are not
# valid patterns on their own; skip them instead of masking
# the RuntimeError raised by `parse`.
self.logger.info('cannot compile fragment %r', pattern)
continue
if match:
self.logger.info(
'matched %r against %r, got: %s',
Expand Down Expand Up @@ -211,13 +221,24 @@
(?P<day>\d{2})
(?P<hour>\d{2})
(?P<minute>\d{2})
(\+(?P<offset>\d{4})|)
((?P<offset_sign>[+-])(?P<offset>\d{4}))?
"""

def __call__(
self, transactions: models.Transactions, value: dict[str, typing.Any]
) -> dict[str, object]:
data = super().__call__(transactions, value)
# The offset subfield is a signed HHMM value (e.g. +0130 is 1 hour
# and 30 minutes east of UTC), while `models.DateTime` expects the
# offset as a number of minutes. Convert it here and drop the raw
# groups so they are not passed on when the offset is absent.
sign: str | None = data.pop('offset_sign', None)
offset: str | None = data.pop('offset', None)
if offset:
minutes: int = int(offset[:2]) * 60 + int(offset[2:])
if sign == '-':
minutes = -minutes
data['offset'] = minutes
return {'date': models.DateTime(**data)}


Expand Down Expand Up @@ -285,8 +306,14 @@
dict[str, str],
super().__call__(transactions, value),
)
if data['status']:
key = data['status'].lower() + '_floor_limit'
# Normalize the D/C mark: a space (sent by e.g. Fiducia/Volksbank,
# 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.

if status:
data['status'] = status
key: str = status.lower() + '_floor_limit'
return {key: models.Amount(**data)}
data_d = data.copy()
data_c = data.copy()
Expand All @@ -309,18 +336,15 @@
Pattern: `2!n35x | *x`
"""

scope = models.TransactionsAndTransaction

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'TransactionsAndTransaction' may not be defined if module
mt940.models
is imported before module
mt940.tags
, as the
definition
of TransactionsAndTransaction occurs after the cyclic
import
of mt940.tags.
id = 'NS'

# NS content is bank specific and free-form, so accept anything
# (including multi-line values whose lines do not all start with a
# two-digit sub-tag); `__call__` extracts the `2!n35x` structure per
# line where present.
pattern = r"""
(?P<non_swift>
(
(\d{2}.{0,})
(\n\d{2}.{0,})*
)|(
[^\n]*
)
)
(?P<non_swift>[\s\S]*)
$"""
sub_pattern = r"""
(?P<ns_id>\d{2})(?P<ns_data>.{0,})
Expand All @@ -340,10 +364,13 @@
ns = frag.groupdict()
value['non_swift_' + ns['ns_id']] = ns['ns_data']
text.append(ns['ns_data'])
elif len(text) and text[-1]:
text.append('')
elif line.strip():
# Free-form line without a two-digit sub-tag: keep the
# content instead of dropping it.
text.append(line.strip())
elif len(text) and text[-1]:
# Blank line: collapse runs into one paragraph separator.
text.append('')
value['non_swift_text'] = '\n'.join(text)
value['non_swift'] = data
return value
Expand Down Expand Up @@ -596,8 +623,13 @@

id = 86
scope = models.Transaction
# The SWIFT spec caps this field at 6 lines of 65 characters, but many
# banks send more. A previous cap of nine 65-char chunks silently
# truncated anything longer, so the capture is unbounded: the parser in
# `models.Transactions.parse` already limits the value to this tag's own
# slice of the statement.
pattern = r"""
(?P<transaction_details>(([\s\S]{0,65}\r?\n?){0,8}[\s\S]{0,65}))
(?P<transaction_details>[\s\S]*)
"""


Expand All @@ -610,7 +642,7 @@
(?P<currency>.{3}) # 3!a Currency
(?P<amount>[\d,]{1,15}) # 15d Amount
"""
status: str
status: ClassVar[str]

def __call__(
self, transactions: models.Transactions, value: dict[str, typing.Any]
Expand Down
Loading
Loading