-
-
Notifications
You must be signed in to change notification settings - Fork 55
Full audit: fix every confirmed issue #127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
2d37679
ab773c6
26b2eb3
36b5e66
5735f2d
427cf06
243d387
f8704dd
18a6d95
ff3d24a
bcbcc9f
40fb4c6
3c0dbfe
0ef2211
9b2e094
7631073
a35284e
fef884f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -78,6 +78,7 @@ | |||||
| import logging | ||||||
| import re | ||||||
| import typing | ||||||
| from typing import ClassVar | ||||||
|
|
||||||
| from . import models | ||||||
|
|
||||||
|
|
@@ -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 failureCode scanning / CodeQL Module-level cyclic import Error
'Transactions' may not be defined if module
mt940.models Error loading related location Loading mt940.tags Error loading related location Loading definition Error loading related location Loading import Error loading related location Loading |
||||||
|
|
||||||
| ) | ||||||
| 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) | ||||||
|
|
@@ -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), | ||||||
|
|
@@ -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, | ||||||
|
|
@@ -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', | ||||||
|
|
@@ -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)} | ||||||
|
|
||||||
|
|
||||||
|
|
@@ -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() | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To enforce defensive programming and prevent potential
Suggested change
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||
| if status: | ||||||
| data['status'] = status | ||||||
| key: str = status.lower() + '_floor_limit' | ||||||
| return {key: models.Amount(**data)} | ||||||
| data_d = data.copy() | ||||||
| data_c = data.copy() | ||||||
|
|
@@ -309,18 +336,15 @@ | |||||
| Pattern: `2!n35x | *x` | ||||||
| """ | ||||||
|
|
||||||
| scope = models.TransactionsAndTransaction | ||||||
Check failureCode scanning / CodeQL Module-level cyclic import Error
'TransactionsAndTransaction' may not be defined if module
mt940.models Error loading related location Loading mt940.tags Error loading related location Loading definition Error loading related location Loading import Error loading related location Loading |
||||||
| 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,}) | ||||||
|
|
@@ -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 | ||||||
|
|
@@ -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]*) | ||||||
| """ | ||||||
|
|
||||||
|
|
||||||
|
|
@@ -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] | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overwriting existing non-None values with
Nonefrom subsequent structured:86:tags causes silent data loss (e.g., clobbering thecustomer_referenceset by:61:). We should only update the transaction data if the incoming valuevis notNone, or if the key does not exist in the transaction data yet. This resolves the clobbering issue and allows the pendingtest_repeated_structured_86_preserves_real_valuestest to pass successfully.There was a problem hiding this comment.
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 thecustomer_referenceset by:61:(e.g.NONREF) and the goldens pincustomer_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 xfailtest_repeated_structured_86_preserves_real_valuesand the PR description's Needs decision section. This PR ships only the non-debatable part: theTypeErrorcrash fix.