reconcile: Fix a mutation bug causing not all matches to be passed through.

This commit is contained in:
Ben Sturmfels 2022-03-02 12:30:56 +11:00
parent fb5d0a57f3
commit 20d242c7c7
Signed by: bsturmfels
GPG key ID: 023C05E2C9C068F0
2 changed files with 54 additions and 20 deletions

View file

@ -104,12 +104,15 @@ import logging
import os import os
import re import re
import sys import sys
from typing import Callable, Dict, List, Tuple, TextIO from typing import Callable, Dict, List, Optional, Sequence, Tuple, TextIO
from beancount import loader from beancount import loader
from beancount.query.query import run_query from beancount.query.query import run_query
from colorama import Fore, Style # type: ignore from colorama import Fore, Style # type: ignore
from .. import cliutil
from .. import config as configmod
if not sys.warnoptions: if not sys.warnoptions:
import warnings import warnings
# Disable annoying warning from thefuzz prompting for a C extension. The # Disable annoying warning from thefuzz prompting for a C extension. The
@ -117,12 +120,12 @@ if not sys.warnoptions:
warnings.filterwarnings('ignore', category=UserWarning, module='thefuzz.fuzz') warnings.filterwarnings('ignore', category=UserWarning, module='thefuzz.fuzz')
from thefuzz import fuzz # type: ignore from thefuzz import fuzz # type: ignore
logger = logging.getLogger() PROGNAME = 'reconcile-statement'
logger.setLevel(logging.INFO) logger = logging.getLogger(__name__)
# Console logging.
logger.addHandler(logging.StreamHandler())
# Get some interesting feedback on call to RT with this:
# logger.setLevel(logging.DEBUG)
# logger.addHandler(logging.StreamHandler())
JUNK_WORDS = [ JUNK_WORDS = [
'software', 'software',
@ -457,8 +460,16 @@ def parse_repo_relative_path(path: str) -> str:
return path return path
def parse_args(argv: List[str]) -> argparse.Namespace: def parse_decimal_with_separator(number_text: str) -> decimal.Decimal:
parser = argparse.ArgumentParser(description='Reconciliation helper') """decimal.Decimal can't parse numbers with thousands separator."""
number_text = number_text.replace(',', '')
return decimal.Decimal(number_text)
def parse_arguments(argv: List[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog=PROGNAME, description='Reconciliation helper')
cliutil.add_version_argument(parser)
cliutil.add_loglevel_argument(parser)
parser.add_argument('--beancount-file', required=True, type=parse_path) parser.add_argument('--beancount-file', required=True, type=parse_path)
parser.add_argument('--csv-statement', required=True, type=parse_repo_relative_path) parser.add_argument('--csv-statement', required=True, type=parse_repo_relative_path)
parser.add_argument('--bank-statement', required=True, type=parse_repo_relative_path) parser.add_argument('--bank-statement', required=True, type=parse_repo_relative_path)
@ -466,9 +477,10 @@ def parse_args(argv: List[str]) -> argparse.Namespace:
parser.add_argument('--grep-output-filename') parser.add_argument('--grep-output-filename')
# parser.add_argument('--report-group-regex') # parser.add_argument('--report-group-regex')
parser.add_argument('--show-reconciled-matches', action='store_true') parser.add_argument('--show-reconciled-matches', action='store_true')
parser.add_argument('--statement-balance', type=decimal.Decimal, required=True, help="A.K.A \"cleared balance\" taken from the end of the period on the PDF statement. Required because CSV statements don't include final or running totals") parser.add_argument('--statement-balance', type=parse_decimal_with_separator, required=True, help="A.K.A \"cleared balance\" taken from the end of the period on the PDF statement. Required because CSV statements don't include final or running totals")
parser.add_argument('--non-interactive', action='store_true', help="Don't prompt to write to the books") parser.add_argument('--non-interactive', action='store_true', help="Don't prompt to write to the books")
return parser.parse_args(args=argv[1:]) args = parser.parse_args(args=argv)
return args
def totals(matches: List[Tuple[List, List, List]]) -> Tuple[decimal.Decimal, decimal.Decimal, decimal.Decimal]: def totals(matches: List[Tuple[List, List, List]]) -> Tuple[decimal.Decimal, decimal.Decimal, decimal.Decimal]:
@ -512,9 +524,6 @@ def subset_match(statement_trans: List[dict], books_trans: List[dict]) -> Tuple[
matches.append(([statement_trans[best_match_index]], group_items, best_match_note)) matches.append(([statement_trans[best_match_index]], group_items, best_match_note))
if best_match_index is not None: if best_match_index is not None:
del statement_trans[best_match_index] del statement_trans[best_match_index]
for item in group_items:
# TODO: Why?
books_trans.remove(item)
else: else:
remaining_books_trans.append(r2) remaining_books_trans.append(r2)
for r1 in statement_trans: for r1 in statement_trans:
@ -531,7 +540,17 @@ def process_unmatched(statement_trans: List[dict], books_trans: List[dict]) -> L
return matches return matches
def main(args: argparse.Namespace) -> None: def main(arglist: Optional[Sequence[str]] = None,
stdout: TextIO = sys.stdout,
stderr: TextIO = sys.stderr,
config: Optional[configmod.Config] = None,
) -> int:
args = parse_arguments(arglist)
cliutil.set_loglevel(logger, args.loglevel)
if config is None:
config = configmod.Config()
config.load_file()
# TODO: Should put in a sanity check to make sure the statement you're feeding # TODO: Should put in a sanity check to make sure the statement you're feeding
# in matches the account you've provided. # in matches the account you've provided.
@ -607,10 +626,7 @@ def main(args: argparse.Namespace) -> None:
write_metadata_to_books(metadata_to_apply) write_metadata_to_books(metadata_to_apply)
if __name__ == '__main__': entry_point = cliutil.make_entry_point(__name__, PROGNAME)
args = parse_args(sys.argv)
main(args)
def entry_point(): if __name__ == '__main__':
args = parse_args(sys.argv) exit(entry_point())
main(args)

View file

@ -308,3 +308,21 @@ def test_subset_sum_match():
[], # No remaining statement trans. [], # No remaining statement trans.
[], # No remaining books trans. [], # No remaining books trans.
) )
def test_subset_passes_through_all_non_matches():
"""This was used to locate a bug where some of the non-matches had
gone missing due to mutation of books_trans."""
statement_trans = [
S1, # No match
S4, # Match
]
books_trans = [
B2, # No match
B4A, B4B, B4C, # Match
B3_next_day, B3_next_week, # No match
]
assert subset_match(statement_trans, books_trans) == (
[([S4], [B4A, B4B, B4C], [])], # Matched
[S1], # No match: preserved intact
[B2, B3_next_day, B3_next_week] # No match: preserved intact
)